diff --git a/src/api.rs b/src/api.rs index 65675f2..b174ed8 100644 --- a/src/api.rs +++ b/src/api.rs @@ -6,6 +6,8 @@ //! and reuses its connection pool across multiple calls (e.g. `me` + //! `get_module` + `create_module` from `module init`). +use std::collections::BTreeMap; + use reqwest::blocking::{Client, Response}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -111,6 +113,64 @@ pub fn tunnel_token( Err(unexpected_body_error(resp)) } +/// GET /v1/tunnel/manifest/{moduleId} — the module's live manifest, proxied +/// off its `mirrorstack dev` tunnel by the dispatch service. `module_id` is +/// the raw platform UUID. The 200 body is the manifest object itself (no +/// envelope), returned as opaque JSON. Owner-gated: someone else's (or an +/// unknown) module is a 404 `not_found`, and a module without a live tunnel +/// is a 503 `tunnel_offline` — callers branch on those codes to tell "start +/// `mirrorstack dev`" apart from real failures. +pub fn get_tunnel_manifest( + http: &Client, + dispatch_base: &str, + access_token: &str, + module_id: &str, +) -> Result { + let endpoint = format!( + "{}/v1/tunnel/manifest/{}", + dispatch_base.trim_end_matches('/'), + module_id + ); + + let resp = http + .get(&endpoint) + .bearer_auth(access_token) + .header("Accept", "application/json") + .send()?; + + let status = resp.status(); + if status.is_success() { + return Ok(resp.json::()?); + } + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(ApiError::Unauthenticated); + } + + // Error envelope: surface code + message so callers can branch on + // `tunnel_offline` / `not_found` without re-parsing the body. + let status_u16 = status.as_u16(); + let body = match http::read_capped(resp) { + Ok(b) => b, + Err(e) => { + return Err(ApiError::Unexpected { + status: status_u16, + body: format!("(read body failed: {e})"), + }); + } + }; + if let Ok(env) = serde_json::from_slice::(&body) { + return Err(ApiError::Server { + status: status_u16, + code: env.error.code, + message: env.error.message, + }); + } + Err(ApiError::Unexpected { + status: status_u16, + body: String::from_utf8_lossy(&body).into_owned(), + }) +} + pub fn me(http: &Client, api_base: &str, access_token: &str) -> Result { let endpoint = format!("{}/v1/auth/me", api_base.trim_end_matches('/')); @@ -214,8 +274,231 @@ pub fn create_module( }) } +#[derive(Debug, Serialize)] +pub struct RecordModuleVersionInput<'a> { + /// Canonical SemVer, no `v` prefix (the platform 422s anything else). + pub version: &'a str, + /// This version's changelog locale map — `{ "default": , "": .md section> }`, each the module's + /// `## ` section extracted off disk. `default` is required; + /// omitted only when empty. Capped server-side at 16KB per value + /// (`changelog_too_large`). + #[serde(skip_serializing_if = "map_is_empty")] + pub changelog: &'a BTreeMap, + /// The module's README locale map — `{ "default": , "": + /// .md> }` read off disk at the module root. Optional and + /// free-form; omitted when empty. Each value is capped client-side at 64KB + /// to match the platform (`readme_too_large`). + #[serde(skip_serializing_if = "map_is_empty")] + pub readme: &'a BTreeMap, + /// The module's full live manifest as read off the dev tunnel + /// (`get_tunnel_manifest`), passed through as opaque JSON. The platform + /// stores it verbatim on the version row and the web console mounts the + /// installed version's UI from it — a stub here loses the module's pages. + pub manifest: &'a serde_json::Value, +} + +/// `skip_serializing_if` predicate for the borrowed changelog / readme maps. +/// The field is a reference, so serde hands the predicate a double reference. +fn map_is_empty(map: &&BTreeMap) -> bool { + map.is_empty() +} + #[derive(Debug, Deserialize)] -#[allow(dead_code)] // name / owner_id / created_at are part of the API surface +#[allow(dead_code)] // module_id / channel / published_at are part of the API surface +pub struct ModuleVersion { + /// Version row UUID. + pub id: String, + pub module_id: String, + pub version: String, + #[serde(default)] + pub channel: Option, + #[serde(default)] + pub published_at: Option, +} + +/// POST /v1/modules/{moduleId}/versions — record an immutable module +/// version (snapshot + changelog). Recording carries no visibility +/// semantics; "publish" is reserved for the future marketplace listing +/// act. Re-recording an existing version is a 409 `version_exists`; the +/// changelog is capped server-side at 16KB (`changelog_too_large`). +pub fn record_module_version( + http: &Client, + apps_base: &str, + access_token: &str, + module_id: &str, + input: &RecordModuleVersionInput, +) -> Result { + let endpoint = format!( + "{}/v1/modules/{}/versions", + apps_base.trim_end_matches('/'), + module_id + ); + + let resp = http + .post(&endpoint) + .bearer_auth(access_token) + .header("Accept", "application/json") + .json(input) + .send()?; + + let status = resp.status(); + if status.is_success() { + return Ok(resp.json::()?); + } + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(ApiError::Unauthenticated); + } + + // 4xx with platform error-envelope: surface code + message so callers + // can branch on `version_exists` / `version_invalid` / + // `changelog_too_large` without re-parsing the body. + let status_u16 = status.as_u16(); + let body = match http::read_capped(resp) { + Ok(b) => b, + Err(e) => { + return Err(ApiError::Unexpected { + status: status_u16, + body: format!("(read body failed: {e})"), + }); + } + }; + if let Ok(env) = serde_json::from_slice::(&body) { + return Err(ApiError::Server { + status: status_u16, + code: env.error.code, + message: env.error.message, + }); + } + Err(ApiError::Unexpected { + status: status_u16, + body: String::from_utf8_lossy(&body).into_owned(), + }) +} + +/// Envelope for GET /v1/modules/{moduleId}/versions. +#[derive(Debug, Deserialize)] +struct ModuleVersionList { + versions: Vec, +} + +/// GET /v1/modules/{moduleId}/versions — the module's recorded versions, +/// newest first (changelogs included, manifests omitted). Owner-scoped; +/// someone else's module is a 404, surfaced as `Unexpected` since deploy +/// resolves ownership via `get_module` before calling this. +pub fn list_module_versions( + http: &Client, + apps_base: &str, + access_token: &str, + module_id: &str, +) -> Result, ApiError> { + let endpoint = format!( + "{}/v1/modules/{}/versions", + apps_base.trim_end_matches('/'), + module_id + ); + + let resp = http + .get(&endpoint) + .bearer_auth(access_token) + .header("Accept", "application/json") + .send()?; + + let status = resp.status(); + if status.is_success() { + return Ok(resp.json::()?.versions); + } + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(ApiError::Unauthenticated); + } + Err(unexpected_body_error(resp)) +} + +#[derive(Debug, Serialize)] +pub struct SetModuleDeployInput<'a> { + pub invoke_target: &'a str, + /// Omitted → server default `active`. + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option<&'a str>, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] // module_id / timestamps are part of the API surface +pub struct ModuleDeploy { + pub version_id: String, + pub module_id: String, + pub invoke_target: String, + pub status: String, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, +} + +/// POST /v1/modules/{moduleId}/versions/{versionRef}/deploy — point a module +/// version at a Lambda invoke target (upsert: one deploy row per version). +/// `version_ref` is the version UUID or the version string — the platform +/// tries the UUID shape first, then resolves UNIQUE(module_id, version). +/// SemVer strings are path-safe verbatim ([0-9A-Za-z.+-] only). `module_id` +/// is the raw platform UUID, not the sanitized `m` form in main.go. +pub fn set_module_deploy( + http: &Client, + apps_base: &str, + access_token: &str, + module_id: &str, + version_ref: &str, + input: &SetModuleDeployInput, +) -> Result { + let endpoint = format!( + "{}/v1/modules/{}/versions/{}/deploy", + apps_base.trim_end_matches('/'), + module_id, + version_ref + ); + + let resp = http + .post(&endpoint) + .bearer_auth(access_token) + .header("Accept", "application/json") + .json(input) + .send()?; + + let status = resp.status(); + if status.is_success() { + return Ok(resp.json::()?); + } + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(ApiError::Unauthenticated); + } + + // 4xx with platform error-envelope: surface code + message so callers + // can branch on `not_found` / `invoke_target_invalid` / `status_invalid` + // without re-parsing the body. + let status_u16 = status.as_u16(); + let body = match http::read_capped(resp) { + Ok(b) => b, + Err(e) => { + return Err(ApiError::Unexpected { + status: status_u16, + body: format!("(read body failed: {e})"), + }); + } + }; + if let Ok(env) = serde_json::from_slice::(&body) { + return Err(ApiError::Server { + status: status_u16, + code: env.error.code, + message: env.error.message, + }); + } + Err(ApiError::Unexpected { + status: status_u16, + body: String::from_utf8_lossy(&body).into_owned(), + }) +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] pub struct App { pub id: String, pub name: String, @@ -385,6 +668,7 @@ fn unexpected_body_error(resp: Response) -> ApiError { mod tests { use super::*; + use std::collections::BTreeMap; use std::time::Duration; use mockito::Server; @@ -669,6 +953,520 @@ mod tests { } } + #[test] + fn set_module_deploy_success() { + let mut server = Server::new(); + let _m = server + .mock("POST", "/v1/modules/mod-uuid/versions/ver-uuid/deploy") + .match_header("authorization", "Bearer AT") + .match_body(mockito::Matcher::JsonString( + r#"{"invoke_target":"my-fn","status":"active"}"#.into(), + )) + .with_status(200) + .with_body( + json!({ + "version_id": "ver-uuid", + "module_id": "mod-uuid", + "invoke_target": "my-fn", + "status": "active", + "created_at": "2026-07-01T00:00:00Z", + "updated_at": "2026-07-02T00:00:00Z" + }) + .to_string(), + ) + .create(); + + let d = set_module_deploy( + &test_client(), + &server.url(), + "AT", + "mod-uuid", + "ver-uuid", + &SetModuleDeployInput { + invoke_target: "my-fn", + status: Some("active"), + }, + ) + .expect("ok"); + assert_eq!(d.version_id, "ver-uuid"); + assert_eq!(d.invoke_target, "my-fn"); + assert_eq!(d.status, "active"); + } + + #[test] + fn set_module_deploy_omits_status_when_none() { + let mut server = Server::new(); + let _m = server + .mock("POST", "/v1/modules/mod-uuid/versions/ver-uuid/deploy") + .match_body(mockito::Matcher::JsonString( + r#"{"invoke_target":"my-fn"}"#.into(), + )) + .with_status(200) + .with_body( + json!({ + "version_id": "ver-uuid", + "module_id": "mod-uuid", + "invoke_target": "my-fn", + "status": "active" + }) + .to_string(), + ) + .create(); + + let d = set_module_deploy( + &test_client(), + &server.url(), + "AT", + "mod-uuid", + "ver-uuid", + &SetModuleDeployInput { + invoke_target: "my-fn", + status: None, + }, + ) + .expect("ok"); + assert_eq!(d.status, "active"); + } + + #[test] + fn set_module_deploy_404_surfaces_code() { + let mut server = Server::new(); + let _m = server + .mock("POST", "/v1/modules/mod-uuid/versions/ver-uuid/deploy") + .with_status(404) + .with_body( + r#"{"error":{"code":"not_found","message":"version not found for this module"}}"#, + ) + .create(); + + let err = set_module_deploy( + &test_client(), + &server.url(), + "AT", + "mod-uuid", + "ver-uuid", + &SetModuleDeployInput { + invoke_target: "my-fn", + status: None, + }, + ) + .unwrap_err(); + match err { + ApiError::Server { status, code, .. } => { + assert_eq!(status, 404); + assert_eq!(code, "not_found"); + } + other => panic!("expected Server, got {other:?}"), + } + } + + #[test] + fn set_module_deploy_422_surfaces_code() { + let mut server = Server::new(); + let _m = server + .mock("POST", "/v1/modules/mod-uuid/versions/ver-uuid/deploy") + .with_status(422) + .with_body( + r#"{"error":{"code":"invoke_target_invalid","message":"invoke_target must be a Lambda function name or ARN"}}"#, + ) + .create(); + + let err = set_module_deploy( + &test_client(), + &server.url(), + "AT", + "mod-uuid", + "ver-uuid", + &SetModuleDeployInput { + invoke_target: "not a lambda!", + status: None, + }, + ) + .unwrap_err(); + match err { + ApiError::Server { status, code, .. } => { + assert_eq!(status, 422); + assert_eq!(code, "invoke_target_invalid"); + } + other => panic!("expected Server, got {other:?}"), + } + } + + #[test] + fn set_module_deploy_401_is_unauthenticated() { + let mut server = Server::new(); + let _m = server + .mock("POST", "/v1/modules/mod-uuid/versions/ver-uuid/deploy") + .with_status(401) + .with_body(r#"{"error":{"code":"token_expired","message":"token expired"}}"#) + .create(); + + let err = set_module_deploy( + &test_client(), + &server.url(), + "expired", + "mod-uuid", + "ver-uuid", + &SetModuleDeployInput { + invoke_target: "my-fn", + status: None, + }, + ) + .unwrap_err(); + assert!(matches!(err, ApiError::Unauthenticated), "got {err:?}"); + } + + #[test] + fn set_module_deploy_accepts_version_string_ref() { + let mut server = Server::new(); + let _m = server + .mock("POST", "/v1/modules/mod-uuid/versions/1.2.0/deploy") + .with_status(200) + .with_body( + json!({ + "version_id": "ver-uuid", + "module_id": "mod-uuid", + "invoke_target": "my-fn", + "status": "active" + }) + .to_string(), + ) + .create(); + + let d = set_module_deploy( + &test_client(), + &server.url(), + "AT", + "mod-uuid", + "1.2.0", + &SetModuleDeployInput { + invoke_target: "my-fn", + status: None, + }, + ) + .expect("ok"); + assert_eq!(d.version_id, "ver-uuid"); + } + + /// Small manifest Value for record tests that don't exercise the body. + fn stub_manifest() -> serde_json::Value { + json!({"id": "mabc123", "slug": "media"}) + } + + #[test] + fn record_module_version_sends_manifest_verbatim() { + let mut server = Server::new(); + // A full manifest (pages, routes) must survive as-is — the platform + // stores it on the version row and mounts the module's UI from it. + // Changelog and README both ship as locale maps: `default` plus any + // `` translation, all frozen on the version row. + let _m = server + .mock("POST", "/v1/modules/mod-uuid/versions") + .match_header("authorization", "Bearer AT") + .match_body(mockito::Matcher::JsonString( + r##"{"version":"0.1.0","changelog":{"default":"- initial release","zh-TW":"- 初始版本"},"readme":{"default":"# Media\n\nLong-form module docs.","zh-TW":"# 媒體\n\n模組長篇說明。"},"manifest":{"id":"mabc123","slug":"media","ui":{"defaultPages":[{"path":"/"}]},"routes":{"public":[{"method":"GET","path":"/public/me"}]}}}"##.into(), + )) + .with_status(201) + .with_body( + json!({ + "id": "ver-uuid", + "module_id": "mod-uuid", + "version": "0.1.0", + "channel": "stable", + "published_at": "2026-07-02T00:00:00Z" + }) + .to_string(), + ) + .create(); + + let manifest = json!({ + "id": "mabc123", + "slug": "media", + "ui": {"defaultPages": [{"path": "/"}]}, + "routes": {"public": [{"method": "GET", "path": "/public/me"}]} + }); + let changelog = BTreeMap::from([ + ("default".to_string(), "- initial release".to_string()), + ("zh-TW".to_string(), "- 初始版本".to_string()), + ]); + let readme = BTreeMap::from([ + ( + "default".to_string(), + "# Media\n\nLong-form module docs.".to_string(), + ), + ("zh-TW".to_string(), "# 媒體\n\n模組長篇說明。".to_string()), + ]); + let v = record_module_version( + &test_client(), + &server.url(), + "AT", + "mod-uuid", + &RecordModuleVersionInput { + version: "0.1.0", + changelog: &changelog, + readme: &readme, + manifest: &manifest, + }, + ) + .expect("ok"); + assert_eq!(v.id, "ver-uuid"); + assert_eq!(v.version, "0.1.0"); + assert_eq!(v.channel.as_deref(), Some("stable")); + } + + #[test] + fn record_module_version_omits_changelog_when_empty() { + let mut server = Server::new(); + let _m = server + .mock("POST", "/v1/modules/mod-uuid/versions") + .match_body(mockito::Matcher::JsonString( + r#"{"version":"0.1.0","manifest":{"id":"mabc123","slug":"media"}}"#.into(), + )) + .with_status(201) + .with_body( + json!({ + "id": "ver-uuid", + "module_id": "mod-uuid", + "version": "0.1.0" + }) + .to_string(), + ) + .create(); + + let v = record_module_version( + &test_client(), + &server.url(), + "AT", + "mod-uuid", + &RecordModuleVersionInput { + version: "0.1.0", + changelog: &BTreeMap::new(), + readme: &BTreeMap::new(), + manifest: &stub_manifest(), + }, + ) + .expect("ok"); + assert_eq!(v.published_at, None); + } + + #[test] + fn record_module_version_409_surfaces_code() { + let mut server = Server::new(); + let _m = server + .mock("POST", "/v1/modules/mod-uuid/versions") + .with_status(409) + .with_body( + r#"{"error":{"code":"version_exists","message":"this version is already published"}}"#, + ) + .create(); + + let err = record_module_version( + &test_client(), + &server.url(), + "AT", + "mod-uuid", + &RecordModuleVersionInput { + version: "0.1.0", + changelog: &BTreeMap::new(), + readme: &BTreeMap::new(), + manifest: &stub_manifest(), + }, + ) + .unwrap_err(); + match err { + ApiError::Server { status, code, .. } => { + assert_eq!(status, 409); + assert_eq!(code, "version_exists"); + } + other => panic!("expected Server, got {other:?}"), + } + } + + #[test] + fn record_module_version_422_surfaces_code() { + let mut server = Server::new(); + let _m = server + .mock("POST", "/v1/modules/mod-uuid/versions") + .with_status(422) + .with_body( + r#"{"error":{"code":"changelog_too_large","message":"changelog must be 16384 characters or fewer"}}"#, + ) + .create(); + + let err = record_module_version( + &test_client(), + &server.url(), + "AT", + "mod-uuid", + &RecordModuleVersionInput { + version: "0.1.0", + changelog: &BTreeMap::from([("default".to_string(), "huge".to_string())]), + readme: &BTreeMap::new(), + manifest: &stub_manifest(), + }, + ) + .unwrap_err(); + match err { + ApiError::Server { status, code, .. } => { + assert_eq!(status, 422); + assert_eq!(code, "changelog_too_large"); + } + other => panic!("expected Server, got {other:?}"), + } + } + + #[test] + fn record_module_version_401_is_unauthenticated() { + let mut server = Server::new(); + let _m = server + .mock("POST", "/v1/modules/mod-uuid/versions") + .with_status(401) + .with_body(r#"{"error":{"code":"token_expired","message":"token expired"}}"#) + .create(); + + let err = record_module_version( + &test_client(), + &server.url(), + "expired", + "mod-uuid", + &RecordModuleVersionInput { + version: "0.1.0", + changelog: &BTreeMap::new(), + readme: &BTreeMap::new(), + manifest: &stub_manifest(), + }, + ) + .unwrap_err(); + assert!(matches!(err, ApiError::Unauthenticated), "got {err:?}"); + } + + #[test] + fn list_module_versions_success() { + let mut server = Server::new(); + let _m = server + .mock("GET", "/v1/modules/mod-uuid/versions") + .match_header("authorization", "Bearer AT") + .with_status(200) + .with_body( + json!({ + "versions": [ + {"id": "ver-2", "module_id": "mod-uuid", "version": "0.2.0"}, + {"id": "ver-1", "module_id": "mod-uuid", "version": "0.1.0"} + ] + }) + .to_string(), + ) + .create(); + + let vs = list_module_versions(&test_client(), &server.url(), "AT", "mod-uuid").expect("ok"); + assert_eq!(vs.len(), 2); + assert_eq!(vs[0].version, "0.2.0"); + assert_eq!(vs[1].id, "ver-1"); + } + + #[test] + fn list_module_versions_empty() { + let mut server = Server::new(); + let _m = server + .mock("GET", "/v1/modules/mod-uuid/versions") + .with_status(200) + .with_body(json!({"versions": []}).to_string()) + .create(); + + let vs = list_module_versions(&test_client(), &server.url(), "AT", "mod-uuid").expect("ok"); + assert!(vs.is_empty()); + } + + #[test] + fn list_module_versions_401_is_unauthenticated() { + let mut server = Server::new(); + let _m = server + .mock("GET", "/v1/modules/mod-uuid/versions") + .with_status(401) + .create(); + + let err = + list_module_versions(&test_client(), &server.url(), "expired", "mod-uuid").unwrap_err(); + assert!(matches!(err, ApiError::Unauthenticated), "got {err:?}"); + } + + #[test] + fn get_tunnel_manifest_success() { + let mut server = Server::new(); + let _m = server + .mock("GET", "/v1/tunnel/manifest/mod-uuid") + .match_header("authorization", "Bearer AT") + .with_status(200) + .with_body( + json!({ + "id": "mabc123", + "slug": "media", + "ui": {"defaultPages": [{"path": "/"}, {"path": "/settings"}]}, + "routes": {"public": []} + }) + .to_string(), + ) + .create(); + + let m = get_tunnel_manifest(&test_client(), &server.url(), "AT", "mod-uuid").expect("ok"); + assert_eq!(m["slug"], "media"); + assert_eq!(m["ui"]["defaultPages"].as_array().map(Vec::len), Some(2)); + } + + #[test] + fn get_tunnel_manifest_401_is_unauthenticated() { + let mut server = Server::new(); + let _m = server + .mock("GET", "/v1/tunnel/manifest/mod-uuid") + .with_status(401) + .with_body(r#"{"error":{"code":"token_expired","message":"token expired"}}"#) + .create(); + + let err = + get_tunnel_manifest(&test_client(), &server.url(), "expired", "mod-uuid").unwrap_err(); + assert!(matches!(err, ApiError::Unauthenticated), "got {err:?}"); + } + + #[test] + fn get_tunnel_manifest_404_surfaces_code() { + let mut server = Server::new(); + let _m = server + .mock("GET", "/v1/tunnel/manifest/mod-uuid") + .with_status(404) + .with_body(r#"{"error":{"code":"not_found","message":"module not found"}}"#) + .create(); + + let err = get_tunnel_manifest(&test_client(), &server.url(), "AT", "mod-uuid").unwrap_err(); + match err { + ApiError::Server { status, code, .. } => { + assert_eq!(status, 404); + assert_eq!(code, "not_found"); + } + other => panic!("expected Server, got {other:?}"), + } + } + + #[test] + fn get_tunnel_manifest_503_tunnel_offline_surfaces_code() { + let mut server = Server::new(); + let _m = server + .mock("GET", "/v1/tunnel/manifest/mod-uuid") + .with_status(503) + .with_body( + r#"{"error":{"code":"tunnel_offline","message":"no live dev tunnel for this module"}}"#, + ) + .create(); + + let err = get_tunnel_manifest(&test_client(), &server.url(), "AT", "mod-uuid").unwrap_err(); + match err { + ApiError::Server { status, code, .. } => { + assert_eq!(status, 503); + assert_eq!(code, "tunnel_offline"); + } + other => panic!("expected Server, got {other:?}"), + } + } + #[test] fn create_module_4xx_without_envelope_is_unexpected() { let mut server = Server::new(); diff --git a/src/commands/dev/module_meta.rs b/src/commands/dev/module_meta.rs index 1810009..6f7fa0c 100644 --- a/src/commands/dev/module_meta.rs +++ b/src/commands/dev/module_meta.rs @@ -1,8 +1,8 @@ //! Read the developer's module identity off the scaffolded source tree. //! -//! Parses `Config.ID`, `Config.Slug`, and `Config.Name` out of `main.go`. -//! These fields drive tunnel registration, platform registration, and -//! the module display name. +//! Parses `Config.ID`, `Config.Slug`, `Config.Name`, and the newest +//! `Config.Versions` key out of `main.go`. These fields drive tunnel +//! registration, platform registration, and deploy. use std::fs; use std::path::Path; @@ -15,9 +15,12 @@ pub(crate) struct ModuleMeta { pub id: String, pub slug: String, pub name: String, + /// Newest `Config.Versions` map key, verbatim (e.g. "v0.1.0-dev"). + /// None when the map is absent or has no SemVer-shaped keys. + pub version: Option, } -/// Read ID, Slug, and Name from `main.go` in `module_dir`. +/// Read ID, Slug, Name, and the newest version from `main.go` in `module_dir`. pub(crate) fn read_module_meta(module_dir: &Path) -> Result { let path = module_dir.join("main.go"); let body = @@ -30,7 +33,13 @@ pub(crate) fn read_module_meta(module_dir: &Path) -> Result { ) })?; let name = extract_field(&body, "Name").unwrap_or_else(|| slug.clone()); - Ok(ModuleMeta { id, slug, name }) + let version = latest_version(&extract_versions_keys(&body)); + Ok(ModuleMeta { + id, + slug, + name, + version, + }) } /// Convenience wrapper that returns just the ID (for tunnel registration). @@ -59,6 +68,182 @@ fn extract_field(go_source: &str, field: &str) -> Option { Some(val.to_string()) } +/// Collect the keys of the `Versions: map[...]...{...}` literal in Go +/// source. Brace-balanced scan of the map block: a quoted string followed +/// by `:` at depth 1 is a key (nested literals and `//` comments are +/// skipped, so an example version in a comment is never picked up). +fn extract_versions_keys(go_source: &str) -> Vec { + let Some(label) = go_source.find("Versions:") else { + return Vec::new(); + }; + let after_label = &go_source[label + "Versions:".len()..]; + let Some(open) = after_label.find('{') else { + return Vec::new(); + }; + let block = &after_label[open + 1..]; + let bytes = block.as_bytes(); + + let mut keys = Vec::new(); + let mut depth = 1usize; + let mut i = 0; + while i < bytes.len() && depth > 0 { + match bytes[i] { + b'/' if bytes.get(i + 1) == Some(&b'/') => { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + } + b'{' => { + depth += 1; + i += 1; + } + b'}' => { + depth -= 1; + i += 1; + } + b'"' => { + let start = i + 1; + let mut j = start; + while j < bytes.len() && bytes[j] != b'"' { + if bytes[j] == b'\\' { + j += 1; + } + j += 1; + } + if j >= bytes.len() { + break; + } + let mut k = j + 1; + while k < bytes.len() && (bytes[k] == b' ' || bytes[k] == b'\t') { + k += 1; + } + if depth == 1 && bytes.get(k) == Some(&b':') { + keys.push(block[start..j].to_string()); + } + i = j + 1; + } + _ => i += 1, + } + } + keys +} + +/// Pick the highest SemVer among the Versions map keys, returned verbatim +/// (keys keep the SDK's `v` prefix). Keys that don't parse as SemVer are +/// ignored — multi-entry maps keep historical tags around, and the newest +/// release is the one deploy acts on. +pub(crate) fn latest_version(keys: &[String]) -> Option { + keys.iter() + .filter_map(|k| parse_semver(k.strip_prefix('v').unwrap_or(k)).map(|v| (v, k))) + .max_by(|(a, _), (b, _)| a.cmp(b)) + .map(|(_, k)| k.clone()) +} + +/// Minimal SemVer 2.0 precedence model — core triple + prerelease ids +/// (build metadata stripped, ignored for precedence). Hand-rolled instead +/// of pulling the `semver` crate for one compare, same call as the inlined +/// slug regex in module/mod.rs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SemVer { + core: (u64, u64, u64), + pre: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum PreId { + // Variant order is load-bearing: derived Ord gives Num < Alpha, which + // is SemVer's "numeric identifiers have lower precedence" rule. + Num(u64), + Alpha(String), +} + +impl Ord for SemVer { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + use std::cmp::Ordering; + self.core.cmp(&other.core).then_with(|| { + match (self.pre.is_empty(), other.pre.is_empty()) { + (true, true) => Ordering::Equal, + // A release outranks any prerelease of the same core. + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => self.pre.cmp(&other.pre), + } + }) + } +} + +impl PartialOrd for SemVer { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Parse canonical SemVer (no `v` prefix). Mirrors the platform's +/// `module_versions_semver_check` constraint — numeric parts and numeric +/// prerelease ids reject leading zeros — so anything the record-version +/// endpoint would 422 parses as None here. +pub(crate) fn parse_semver(s: &str) -> Option { + let s = s.split_once('+').map_or(s, |(rest, _)| rest); + let (core, pre) = match s.split_once('-') { + Some((c, p)) => (c, Some(p)), + None => (s, None), + }; + let mut parts = core.split('.'); + let (a, b, c) = (parts.next()?, parts.next()?, parts.next()?); + if parts.next().is_some() { + return None; + } + let core = (num_part(a)?, num_part(b)?, num_part(c)?); + let mut ids = Vec::new(); + if let Some(pre) = pre { + for id in pre.split('.') { + ids.push(pre_id(id)?); + } + } + Some(SemVer { core, pre: ids }) +} + +fn num_part(s: &str) -> Option { + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) || (s.len() > 1 && s.starts_with('0')) + { + return None; + } + s.parse().ok() +} + +fn pre_id(s: &str) -> Option { + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') { + return None; + } + if s.bytes().all(|b| b.is_ascii_digit()) { + if s.len() > 1 && s.starts_with('0') { + return None; + } + return Some(PreId::Num(s.parse().ok()?)); + } + Some(PreId::Alpha(s.to_string())) +} + +/// Rename a `Versions` map key in `main.go` — the deploy promote flow +/// ("v0.1.0-dev" → "v0.1.0"). Only the first occurrence after the +/// `Versions:` label is touched, so identical strings elsewhere in the +/// file are safe. +pub(crate) fn promote_version(module_dir: &Path, from: &str, to: &str) -> Result<()> { + let path = module_dir.join("main.go"); + let body = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let label = body + .find("Versions:") + .ok_or_else(|| anyhow!("no Versions field in {}", path.display()))?; + let needle = format!("\"{from}\""); + let rel = body[label..] + .find(&needle) + .ok_or_else(|| anyhow!("no \"{from}\" key under Versions in {}", path.display()))?; + let abs = label + rel; + let new_body = format!("{}\"{to}\"{}", &body[..abs], &body[abs + needle.len()..]); + fs::write(&path, new_body).with_context(|| format!("write {}", path.display()))?; + Ok(()) +} + /// Write `new_id` into the `ID: "..."` field in `main.go`. If the field /// has an empty string (`ID: ""`), it's replaced. If the field is missing /// entirely, it's inserted after the `Slug:` line. @@ -140,6 +325,11 @@ func main() { Slug: "media", Name: "Media", Icon: "extension", + Versions: map[string]system.MigrationVersions{ + // `-dev` marks local iteration; `mirrorstack module deploy` + // promotes it before shipping. + "v0.1.0-dev": {App: "0001"}, + }, }); err != nil { log.Fatalf("init: %v", err) } @@ -189,6 +379,103 @@ func main() { assert_eq!(meta.id, "mbb8a3f8b123456789abcdef012345678"); assert_eq!(meta.slug, "media"); assert_eq!(meta.name, "Media"); + assert_eq!(meta.version.as_deref(), Some("v0.1.0-dev")); + } + + #[test] + fn version_parsed_from_versions_map_preserves_dev_suffix() { + let keys = extract_versions_keys(SAMPLE_MAIN_GO); + assert_eq!(keys, vec!["v0.1.0-dev".to_string()]); + assert_eq!(latest_version(&keys).as_deref(), Some("v0.1.0-dev")); + } + + #[test] + fn version_picks_max_semver_of_multiple_keys() { + let src = r#" + Versions: map[string]system.MigrationVersions{ + "v0.9.0": {App: "0003"}, + "v0.10.0": {App: "0004", Module: "0001"}, + "v0.2.0": {App: "0001"}, + }, + "#; + let keys = extract_versions_keys(src); + assert_eq!(keys.len(), 3, "got {keys:?}"); + // 0.10.0 > 0.9.0 numerically (lexical compare would pick 0.9.0). + assert_eq!(latest_version(&keys).as_deref(), Some("v0.10.0")); + } + + #[test] + fn version_none_when_versions_absent() { + let src = r#"ms.Config{ Slug: "media", Name: "Media" }"#; + assert!(extract_versions_keys(src).is_empty()); + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("main.go"), src).unwrap(); + assert_eq!(read_module_meta(tmp.path()).unwrap().version, None); + } + + #[test] + fn extract_versions_keys_skips_comments_and_nested_strings() { + let src = r#" + Versions: map[string]system.MigrationVersions{ + // example: "v9.9.9": {App: "0001"}, + "v1.0.0": {App: "0001", Module: "0002"}, + }, + "#; + assert_eq!(extract_versions_keys(src), vec!["v1.0.0".to_string()]); + } + + #[test] + fn latest_version_prerelease_loses_to_release() { + let keys = vec!["v1.0.0-rc.1".to_string(), "v1.0.0".to_string()]; + assert_eq!(latest_version(&keys).as_deref(), Some("v1.0.0")); + } + + #[test] + fn parse_semver_orders_prerelease_ids() { + let cmp = |a: &str, b: &str| parse_semver(a).unwrap().cmp(&parse_semver(b).unwrap()); + assert_eq!( + cmp("1.0.0-alpha", "1.0.0-alpha.1"), + std::cmp::Ordering::Less + ); + assert_eq!(cmp("1.0.0-2", "1.0.0-11"), std::cmp::Ordering::Less); + assert_eq!(cmp("1.0.0-1", "1.0.0-alpha"), std::cmp::Ordering::Less); + assert_eq!(cmp("1.0.0+build.1", "1.0.0"), std::cmp::Ordering::Equal); + } + + #[test] + fn parse_semver_rejects_non_canonical() { + for s in [ + "v1.0.0", + "1.0", + "01.2.3", + "1.2.3-", + "1.2.3-0.03", + "1.2.3.4", + "", + ] { + assert!(parse_semver(s).is_none(), "expected {s:?} invalid"); + } + } + + #[test] + fn promote_version_rewrites_key() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("main.go"), SAMPLE_MAIN_GO).unwrap(); + promote_version(tmp.path(), "v0.1.0-dev", "v0.1.0").unwrap(); + let meta = read_module_meta(tmp.path()).unwrap(); + assert_eq!(meta.version.as_deref(), Some("v0.1.0")); + // Only the Versions key changed; identity fields are untouched. + assert_eq!(meta.slug, "media"); + } + + #[test] + fn promote_version_errors_when_key_absent() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("main.go"), SAMPLE_MAIN_GO).unwrap(); + let err = promote_version(tmp.path(), "v2.0.0-dev", "v2.0.0") + .unwrap_err() + .to_string(); + assert!(err.contains("v2.0.0-dev"), "got {err}"); } #[test] diff --git a/src/commands/module/changelog.rs b/src/commands/module/changelog.rs new file mode 100644 index 0000000..e5a7dda --- /dev/null +++ b/src/commands/module/changelog.rs @@ -0,0 +1,413 @@ +//! CHANGELOG locale-map lint for `module deploy`'s record step. +//! +//! CHANGELOG files at the module root are the version log: deploy extracts each +//! one's `## ` section and records a locale map on the version row. +//! `CHANGELOG.md` is the `default`; `CHANGELOG..md` (e.g. +//! `CHANGELOG.zh-TW.md`) contributes a locale translation. The default is +//! required and hard-linted — file missing, no (or multiple) headings for the +//! version, empty body, or a section over the platform cap are all errors, and +//! ordering/duplicate anomalies are returned as warnings for the caller to +//! print. Locale variants are optional and best-effort: a `CHANGELOG..md` +//! that lacks this version's section is simply omitted from the map, no error. + +use std::collections::{BTreeMap, HashSet}; +use std::path::Path; + +use anyhow::{Context, Result, anyhow}; + +use crate::commands::dev::module_meta; + +/// Server-side cap on `module_versions.changelog` (16KB). Enforced here so +/// the failure is a lint error, not a `changelog_too_large` round-trip. +const MAX_SECTION_BYTES: usize = 16_384; + +/// A module's changelog for the recorded version: `default` (CHANGELOG.md, +/// always present) plus any `CHANGELOG..md` locale sections. +#[derive(Debug)] +pub(super) struct Changelog { + /// Locale → this version's trimmed section body. Key `default` is + /// CHANGELOG.md; a `` key is CHANGELOG..md. Always carries + /// `default` — its section is a hard requirement. + pub map: BTreeMap, + /// Non-fatal anomalies from the default CHANGELOG.md, one message per line. + pub warnings: Vec, +} + +/// One file's extracted section plus its file-level warnings — the result of +/// linting a single CHANGELOG body. +#[derive(Debug)] +struct ChangelogEntry { + /// Trimmed section body for the recorded version. + body: String, + /// Non-fatal anomalies, one message per line. + warnings: Vec, +} + +/// Lint `CHANGELOG.md` in `module_dir` for `version` (canonical SemVer, no `v` +/// prefix) and collect any `CHANGELOG..md` locale sections into a map. +/// The default file is required — a missing or invalid default section is a +/// hard error; locale variants are optional. +pub(super) fn lint(module_dir: &Path, version: &str) -> Result { + let path = module_dir.join("CHANGELOG.md"); + if !path.exists() { + return Err(anyhow!( + "no CHANGELOG.md in {} — create one with a `## {version}` section describing this release", + module_dir.display() + )); + } + let body = + std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let default = lint_body(&body, version)?; + + let mut map = BTreeMap::new(); + map.insert("default".to_string(), default.body); + for (tag, section) in locale_sections(module_dir, version)? { + map.insert(tag, section); + } + + Ok(Changelog { + map, + warnings: default.warnings, + }) +} + +/// Scan `module_dir` for `CHANGELOG..md` locale files and extract each +/// one's `## ` section. Best-effort: a file that lacks (or can't +/// cleanly yield) the section is skipped — only the default is required. +fn locale_sections(module_dir: &Path, version: &str) -> Result> { + let mut out = Vec::new(); + let entries = match std::fs::read_dir(module_dir) { + Ok(entries) => entries, + // The default file already resolved above; a dir that vanished in + // between is treated as "no locale variants". + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(e).with_context(|| format!("read dir {}", module_dir.display())), + }; + for entry in entries { + let entry = entry.with_context(|| format!("read dir {}", module_dir.display()))?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + let Some(tag) = locale_tag(name) else { + continue; + }; + let path = entry.path(); + if !path.is_file() { + continue; + } + let body = + std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + // A locale file that lacks (empty / absent / oversized / duplicated) + // this version's section is simply omitted, never an error. + if let Ok(section) = lint_body(&body, version) { + out.push((tag, section.body)); + } + } + Ok(out) +} + +/// Locale tag for a `CHANGELOG..md` file name, or None when it isn't a +/// locale changelog. `CHANGELOG.md` (the default, linted separately) and any +/// `` that isn't a single locale-ish token (`[A-Za-z0-9_-]`, matching the +/// platform's key validation) are rejected. +fn locale_tag(file_name: &str) -> Option { + let tag = file_name.strip_prefix("CHANGELOG.")?.strip_suffix(".md")?; + if tag.is_empty() + || !tag + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return None; + } + Some(tag.to_string()) +} + +fn lint_body(changelog: &str, version: &str) -> Result { + let lines: Vec<&str> = changelog.lines().collect(); + // Sections end at ANY `## ` heading (e.g. `## Unreleased`), but only + // version-shaped headings participate in matching and ordering checks. + let mut heading_lines: Vec = Vec::new(); + let mut version_headings: Vec<(usize, String)> = Vec::new(); + for (i, line) in lines.iter().enumerate() { + if let Some(text) = line.strip_prefix("## ") { + heading_lines.push(i); + if let Some(v) = heading_version(text) { + version_headings.push((i, v)); + } + } + } + + let matches: Vec = version_headings + .iter() + .filter(|(_, v)| v == version) + .map(|(i, _)| *i) + .collect(); + let start = match matches.as_slice() { + [] => { + return Err(anyhow!( + "CHANGELOG.md has no `## {version}` section — add one describing this release" + )); + } + [i] => *i, + many => { + return Err(anyhow!( + "CHANGELOG.md has {} `## {version}` headings — keep exactly one", + many.len() + )); + } + }; + + let end = heading_lines + .iter() + .copied() + .find(|&h| h > start) + .unwrap_or(lines.len()); + let body = lines[start + 1..end].join("\n").trim().to_string(); + if body.is_empty() { + return Err(anyhow!( + "the `## {version}` section in CHANGELOG.md is empty — describe the release before deploying" + )); + } + if body.len() > MAX_SECTION_BYTES { + return Err(anyhow!( + "the `## {version}` section in CHANGELOG.md is {} bytes; the platform caps changelogs at {MAX_SECTION_BYTES}", + body.len() + )); + } + + let mut warnings = Vec::new(); + for pair in version_headings.windows(2) { + let (a, b) = (&pair[0].1, &pair[1].1); + // Both came from heading_version, so parse_semver always succeeds. + if module_meta::parse_semver(a) < module_meta::parse_semver(b) { + warnings.push(format!( + "CHANGELOG.md versions are not in descending order ({a} appears above {b})" + )); + break; + } + } + let mut seen = HashSet::new(); + let mut dup_warned = HashSet::new(); + for (_, v) in &version_headings { + if v != version && !seen.insert(v.as_str()) && dup_warned.insert(v.as_str()) { + warnings.push(format!("CHANGELOG.md has duplicate `## {v}` headings")); + } + } + + Ok(ChangelogEntry { body, warnings }) +} + +/// Canonical version from the text after `## `, or None when the heading +/// isn't a version heading (e.g. `## Unreleased`). Tolerates `[x.y.z]` +/// brackets, a `v` prefix, and a trailing ` - ` / ` ()` suffix. +fn heading_version(text: &str) -> Option { + let text = text.trim(); + let (token, rest) = if let Some(inner) = text.strip_prefix('[') { + let (token, rest) = inner.split_once(']')?; + (token.trim(), rest.trim()) + } else { + match text.split_once(char::is_whitespace) { + Some((t, r)) => (t, r.trim()), + None => (text, ""), + } + }; + if !(rest.is_empty() || rest.starts_with('-') || rest.starts_with('(')) { + return None; + } + let canonical = token.strip_prefix('v').unwrap_or(token); + module_meta::parse_semver(canonical)?; + Some(canonical.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "\ +# Changelog + +## Unreleased + +- something brewing + +## 0.2.0 - 2026-07-01 + +- added deploy verb +- fixed tunnel reconnect + +## [0.1.0] (2026-06-20) + +- initial release +"; + + #[test] + fn lint_body_finds_exact_version() { + let entry = lint_body(SAMPLE, "0.2.0").expect("ok"); + assert_eq!(entry.body, "- added deploy verb\n- fixed tunnel reconnect"); + assert!(entry.warnings.is_empty(), "got {:?}", entry.warnings); + } + + #[test] + fn lint_body_tolerates_bracketed_heading_with_date() { + let entry = lint_body(SAMPLE, "0.1.0").expect("ok"); + assert_eq!(entry.body, "- initial release"); + } + + #[test] + fn lint_body_tolerates_v_prefix() { + let entry = lint_body("## v0.1.0\n\n- first\n", "0.1.0").expect("ok"); + assert_eq!(entry.body, "- first"); + } + + #[test] + fn lint_body_slices_until_next_heading() { + // The Unreleased body must not bleed into 0.2.0, nor 0.2.0 into 0.1.0. + let entry = lint_body(SAMPLE, "0.2.0").expect("ok"); + assert!(!entry.body.contains("brewing")); + assert!(!entry.body.contains("initial release")); + } + + #[test] + fn lint_body_ignores_unreleased_section() { + let err = lint_body("## Unreleased\n\n- wip\n", "0.1.0") + .unwrap_err() + .to_string(); + assert!(err.contains("no `## 0.1.0` section"), "got {err}"); + } + + #[test] + fn lint_body_errors_when_version_absent() { + let err = lint_body(SAMPLE, "9.9.9").unwrap_err().to_string(); + assert!(err.contains("no `## 9.9.9` section"), "got {err}"); + } + + #[test] + fn lint_body_errors_for_empty_section() { + let err = lint_body("## 0.1.0\n\n## 0.0.1\n\n- old\n", "0.1.0") + .unwrap_err() + .to_string(); + assert!(err.contains("empty"), "got {err}"); + } + + #[test] + fn lint_body_errors_on_duplicate_target_headings() { + let err = lint_body("## 0.1.0\n\n- a\n\n## 0.1.0\n\n- b\n", "0.1.0") + .unwrap_err() + .to_string(); + assert!(err.contains("keep exactly one"), "got {err}"); + } + + #[test] + fn lint_body_errors_on_oversized_section() { + let big = format!("## 0.1.0\n\n{}\n", "x".repeat(MAX_SECTION_BYTES + 1)); + let err = lint_body(&big, "0.1.0").unwrap_err().to_string(); + assert!(err.contains("caps changelogs"), "got {err}"); + } + + #[test] + fn lint_body_warns_on_ascending_order() { + let entry = lint_body("## 0.1.0\n\n- a\n\n## 0.2.0\n\n- b\n", "0.1.0").expect("ok"); + assert_eq!(entry.warnings.len(), 1, "got {:?}", entry.warnings); + assert!(entry.warnings[0].contains("descending")); + } + + #[test] + fn lint_body_warns_on_duplicate_other_versions() { + let src = "## 0.3.0\n\n- new\n\n## 0.2.0\n\n- a\n\n## 0.2.0\n\n- b\n"; + let entry = lint_body(src, "0.3.0").expect("ok"); + assert_eq!(entry.warnings.len(), 1, "got {:?}", entry.warnings); + assert!(entry.warnings[0].contains("duplicate `## 0.2.0`")); + } + + #[test] + fn lint_missing_file_errors() { + let tmp = tempfile::tempdir().unwrap(); + let err = lint(tmp.path(), "0.1.0").unwrap_err().to_string(); + assert!(err.contains("no CHANGELOG.md"), "got {err}"); + } + + #[test] + fn lint_reads_default_from_disk() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("CHANGELOG.md"), SAMPLE).unwrap(); + let cl = lint(tmp.path(), "0.2.0").expect("ok"); + assert_eq!(cl.map.len(), 1); + assert!(cl.map["default"].contains("deploy verb")); + } + + #[test] + fn lint_collects_locale_variants() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("CHANGELOG.md"), SAMPLE).unwrap(); + std::fs::write( + tmp.path().join("CHANGELOG.zh-TW.md"), + "## 0.2.0\n\n- 加入部署指令\n", + ) + .unwrap(); + let cl = lint(tmp.path(), "0.2.0").expect("ok"); + assert!(cl.map["default"].contains("deploy verb")); + assert_eq!( + cl.map.get("zh-TW").map(String::as_str), + Some("- 加入部署指令") + ); + assert_eq!(cl.map.len(), 2); + } + + #[test] + fn lint_omits_locale_missing_the_version_section() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("CHANGELOG.md"), SAMPLE).unwrap(); + // The French log only documents 0.1.0, not the 0.2.0 being recorded. + std::fs::write( + tmp.path().join("CHANGELOG.fr.md"), + "## 0.1.0\n\n- version initiale\n", + ) + .unwrap(); + let cl = lint(tmp.path(), "0.2.0").expect("ok"); + assert!(!cl.map.contains_key("fr")); + assert_eq!(cl.map.len(), 1); + } + + #[test] + fn lint_default_missing_section_errors_despite_locale() { + let tmp = tempfile::tempdir().unwrap(); + // Default lacks 0.2.0; a locale carrying it must not rescue the record. + std::fs::write(tmp.path().join("CHANGELOG.md"), "## 0.1.0\n\n- old\n").unwrap(); + std::fs::write( + tmp.path().join("CHANGELOG.zh-TW.md"), + "## 0.2.0\n\n- 新版\n", + ) + .unwrap(); + let err = lint(tmp.path(), "0.2.0").unwrap_err().to_string(); + assert!(err.contains("no `## 0.2.0` section"), "got {err}"); + } + + #[test] + fn lint_ignores_non_changelog_files() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("CHANGELOG.md"), SAMPLE).unwrap(); + std::fs::write(tmp.path().join("README.md"), "# Media\n").unwrap(); + std::fs::write(tmp.path().join("CHANGELOG.notes.txt"), "## 0.2.0\n\n- x\n").unwrap(); + let cl = lint(tmp.path(), "0.2.0").expect("ok"); + assert_eq!(cl.map.len(), 1); + assert!(cl.map.contains_key("default")); + } + + #[test] + fn locale_tag_maps_tags_and_rejects_default() { + assert_eq!(locale_tag("CHANGELOG.zh-TW.md").as_deref(), Some("zh-TW")); + assert_eq!(locale_tag("CHANGELOG.en_US.md").as_deref(), Some("en_US")); + assert_eq!(locale_tag("CHANGELOG.md"), None); + assert_eq!(locale_tag("CHANGELOG.notes.txt"), None); + assert_eq!(locale_tag("CHANGELOG..md"), None); + // A multi-dot middle isn't a single locale-ish token. + assert_eq!(locale_tag("CHANGELOG.zh.TW.md"), None); + assert_eq!(locale_tag("CHANGES.fr.md"), None); + } + + #[test] + fn heading_version_rejects_trailing_prose() { + assert_eq!(heading_version("0.1.0 fixed stuff"), None); + assert_eq!(heading_version("0.1.0 - 2026-07-02"), Some("0.1.0".into())); + assert_eq!(heading_version("[v0.1.0]"), Some("0.1.0".into())); + } +} diff --git a/src/commands/module/mod.rs b/src/commands/module/mod.rs index ca2d6c8..e040468 100644 --- a/src/commands/module/mod.rs +++ b/src/commands/module/mod.rs @@ -14,16 +14,21 @@ use console::style; use dialoguer::{Confirm, Input, theme::ColorfulTheme}; use indicatif::{ProgressBar, ProgressStyle}; -use crate::api::{self, ApiError, CreateModuleInput}; -use crate::commands::dev::module_meta; +use crate::api::{ + self, ApiError, CreateModuleInput, RecordModuleVersionInput, SetModuleDeployInput, +}; +use crate::commands::dev::module_meta::{self, ModuleMeta}; use crate::credentials; use crate::http; +mod changelog; +mod readme; mod scaffold; use super::{ - DEFAULT_API_BASE, DEFAULT_APPS_API_BASE, DEFAULT_WEB_BASE, ENV_API_URL, ENV_APPS_API_URL, - ENV_WEB_URL, ok_mark, resolve_base, session_expired, warn_prefix, + DEFAULT_API_BASE, DEFAULT_APPS_API_BASE, DEFAULT_DISPATCH_BASE, DEFAULT_WEB_BASE, ENV_API_URL, + ENV_APPS_API_URL, ENV_DISPATCH_URL, ENV_WEB_URL, ok_mark, resolve_base, session_expired, + warn_prefix, }; #[derive(Args)] @@ -41,6 +46,11 @@ enum ModuleCommand { /// platform. Scans go.work, finds modules with empty IDs, creates /// them via the API, and writes the assigned ID back into main.go. Register(RegisterArgs), + /// Deploy the version your code declares (the newest Config.Versions + /// key in ./main.go) to a live Lambda invoke target. Records the + /// version with its CHANGELOG.md section first when it isn't recorded + /// yet — records are immutable, bump the key to ship a new entry. + Deploy(DeployArgs), } #[derive(Args)] @@ -79,10 +89,29 @@ pub struct RegisterArgs { yes: bool, } +#[derive(Args)] +pub struct DeployArgs { + /// Lambda function name or full ARN, with an optional :qualifier. + #[arg(long)] + target: String, + /// Module slug. Defaults to Config.Slug parsed from ./main.go. + #[arg(long)] + module: Option, + /// Module directory containing main.go, CHANGELOG.md, and optional + /// README.md. Defaults to cwd. + #[arg(long)] + dir: Option, + /// Non-interactive mode. Refuses `-dev` versions instead of offering + /// to promote them. + #[arg(long, short)] + yes: bool, +} + pub fn run(args: ModuleArgs) -> Result<()> { match args.command { ModuleCommand::Init(i) => init(i), ModuleCommand::Register(r) => register(r), + ModuleCommand::Deploy(d) => deploy(d), } } @@ -388,6 +417,365 @@ fn register(args: RegisterArgs) -> Result<()> { Ok(()) } +fn deploy(args: DeployArgs) -> Result<()> { + let dir = args + .dir + .clone() + .unwrap_or_else(|| std::env::current_dir().expect("cwd")); + + // The version always comes from code, so main.go is read even when + // --module overrides the slug. + let meta = read_meta(&dir)?; + let slug = args.module.clone().unwrap_or_else(|| meta.slug.clone()); + if !slug_valid(&slug) { + return Err(anyhow!( + "slug '{slug}' is invalid: must be 3-40 chars, start with a letter, end with a letter or digit, lowercase + hyphen only." + )); + } + + let raw = code_version(&meta, &dir)?; + // `-dev` marks local iteration (scaffold convention). Interactively we + // offer to promote (rename the Versions key, dropping the tag); with + // --yes the caller must rename it in code first. + let (target_raw, promote_from) = match raw.strip_suffix("-dev") { + Some(promoted) => { + if args.yes { + return Err(anyhow!( + "Config version '{raw}' is a -dev prerelease. Rename the Versions key in main.go (e.g. \"{promoted}\") before deploying with --yes." + )); + } + (promoted.to_string(), Some(raw.clone())) + } + None => (raw.clone(), None), + }; + let version = canonical_version(&target_raw)?; + + let creds = credentials::load_or_login_hint()?; + let apps_base = resolve_base(ENV_APPS_API_URL, DEFAULT_APPS_API_BASE); + let dispatch_base = resolve_base(ENV_DISPATCH_URL, DEFAULT_DISPATCH_BASE); + let client = http::client(Duration::from_secs(15))?; + let module = get_owned_module(&client, &apps_base, &creds.access_token, &slug)?; + + if let Some(from) = &promote_from { + eprintln!(); + eprintln!(" {} {}", style("Module:").dim(), style(&slug).bold()); + eprintln!( + " {} {}", + style("Version:").dim(), + style(&version).cyan().bold() + ); + let confirmed = Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(format!( + "Promote {from} → {target_raw} in main.go and deploy {slug}@{version}?" + )) + .default(true) + .interact()?; + if !confirmed { + eprintln!("{}", style("aborted.").yellow()); + return Ok(()); + } + } + + ensure_version_recorded( + &client, + &apps_base, + &dispatch_base, + &creds.access_token, + &module, + &slug, + &version, + &dir, + )?; + + let result = with_spinner("Deploying…", || { + api::set_module_deploy( + &client, + &apps_base, + &creds.access_token, + &module.id, + &version, + &SetModuleDeployInput { + invoke_target: &args.target, + status: None, + }, + ) + }); + + let deploy = match result { + Ok(d) => d, + Err(ApiError::Server { code, message, .. }) => { + return Err(anyhow!( + "{code}: {message}{hint}", + hint = deploy_error_hint(&code) + )); + } + Err(ApiError::Unauthenticated) => return Err(session_expired()), + Err(e) => return Err(e.into()), + }; + + // Write the promoted key back only after the platform accepted the + // whole operation — a failed deploy must not leave main.go + // half-promoted (re-running just prompts and 409-skips the record). + if let Some(from) = &promote_from { + match module_meta::promote_version(&dir, from, &target_raw) { + Ok(()) => eprintln!( + "{} promoted {} → {} in main.go", + ok_mark(), + style(from).dim(), + style(&target_raw).bold() + ), + Err(e) => eprintln!( + "{} deployed, but couldn't update main.go: {e}. Rename the Versions key \"{from}\" → \"{target_raw}\" manually.", + warn_prefix() + ), + } + } + + eprintln!( + "{} deployed {}", + ok_mark(), + style(format!("{slug}@{version}")).cyan().bold() + ); + eprintln!(" {} {}", style("target:").dim(), deploy.invoke_target); + eprintln!(" {} {}", style("status:").dim(), deploy.status); + Ok(()) +} + +/// Make sure `version` exists as a recorded module_versions row before the +/// deploy row is written. Recording is just that — an immutable snapshot + +/// changelog with no visibility semantics ("publish" is the future +/// marketplace listing act, not a prerequisite to run). +/// +/// The record attempt is 409-tolerant so no pre-flight existence check is +/// needed and concurrent deploys can't race: `version_exists` means someone +/// (usually an earlier run) already recorded it, and the deploy proceeds. +/// Local inputs to the record — the changelog and the manifest read off the +/// dev tunnel — are only fatal when the version isn't recorded yet: an +/// existing record froze both, so on failure each branch checks the versions +/// list and proceeds when the record already exists. +#[allow(clippy::too_many_arguments)] +fn ensure_version_recorded( + client: &reqwest::blocking::Client, + apps_base: &str, + dispatch_base: &str, + access_token: &str, + module: &api::Module, + slug: &str, + version: &str, + dir: &Path, +) -> Result<()> { + let entry = match changelog::lint(dir, version) { + Ok(entry) => entry, + Err(lint_err) => { + if version_is_recorded(client, apps_base, access_token, &module.id, version)? { + print_already_recorded(slug, version); + return Ok(()); + } + return Err(lint_err); + } + }; + + // The record freezes the manifest the platform mounts this version with + // (pages, routes, permissions), so it must be the module's real declared + // surface — read off the live dev tunnel, not reconstructed locally. + let fetched = with_spinner("Reading module manifest…", || { + api::get_tunnel_manifest(client, dispatch_base, access_token, &module.id) + }); + let manifest = match fetched { + Ok(m) => m, + Err(ApiError::Unauthenticated) => return Err(session_expired()), + Err(fetch_err) => { + if version_is_recorded(client, apps_base, access_token, &module.id, version)? { + print_already_recorded(slug, version); + return Ok(()); + } + return Err(anyhow!( + "recording a new version needs the module running under `mirrorstack dev` (its manifest is captured at record time) — start it or record later ({fetch_err})" + )); + } + }; + + // README files are the module's long-form description — optional and + // free-form (no lint). `README.md` is the `default`; `README..md` + // adds a locale translation. They're frozen on the version row alongside + // the changelog, so they're read here on the fresh-record path only; an + // empty map (no README files) is omitted from the request. + let readme = readme::read(dir)?; + + let result = with_spinner("Recording version…", || { + api::record_module_version( + client, + apps_base, + access_token, + &module.id, + &RecordModuleVersionInput { + version, + changelog: &entry.map, + readme: &readme.map, + manifest: &manifest, + }, + ) + }); + + match result { + Ok(recorded) => { + // Warnings describe the changelog/readme just frozen; on the + // already-recorded path they'd be noise about files the platform + // no longer reads. + for w in &entry.warnings { + eprintln!("{} {w}", warn_prefix()); + } + if readme.truncated { + eprintln!( + "{} a README file exceeded {} bytes and was truncated for the version record", + warn_prefix(), + readme::MAX_README_BYTES + ); + } + eprintln!( + "{} recorded {}", + ok_mark(), + style(format!("{slug}@{version}")).cyan().bold() + ); + eprintln!(" {} {}", style("id:").dim(), recorded.id); + // `default` (CHANGELOG.md) is always present after a clean lint. + for line in changelog_preview(&entry.map["default"], 3) { + eprintln!(" {} {line}", style("│").dim()); + } + Ok(()) + } + Err(ApiError::Server { code, .. }) if code == "version_exists" => { + print_already_recorded(slug, version); + Ok(()) + } + Err(ApiError::Server { code, message, .. }) => Err(anyhow!( + "{code}: {message}{hint}", + hint = record_error_hint(&code) + )), + Err(ApiError::Unauthenticated) => Err(session_expired()), + Err(e) => Err(e.into()), + } +} + +/// Whether `version` already exists as a recorded row. Used to downgrade +/// failures of the record's local inputs (changelog lint, manifest fetch): +/// when the record already happened those inputs are frozen server-side and +/// the deploy proceeds without them. +fn version_is_recorded( + client: &reqwest::blocking::Client, + apps_base: &str, + access_token: &str, + module_id: &str, + version: &str, +) -> Result { + let listed = with_spinner("Checking recorded versions…", || { + api::list_module_versions(client, apps_base, access_token, module_id) + }); + match listed { + Ok(versions) => Ok(versions.iter().any(|v| v.version == version)), + Err(ApiError::Unauthenticated) => Err(session_expired()), + Err(e) => Err(e.into()), + } +} + +fn print_already_recorded(slug: &str, version: &str) { + eprintln!( + "{} {} is already recorded — its changelog is frozen. Bump the Versions key in main.go to ship a new entry.", + ok_mark(), + style(format!("{slug}@{version}")).cyan() + ); +} + +/// Read main.go metadata with a deploy-flavoured error. +fn read_meta(dir: &Path) -> Result { + module_meta::read_module_meta(dir).map_err(|e| { + anyhow!( + "couldn't read the module from {}: {e}. Run from the module directory, or pass --module / --dir .", + dir.display() + ) + }) +} + +/// The version the code declares — the newest Config.Versions key. +fn code_version(meta: &ModuleMeta, dir: &Path) -> Result { + meta.version.clone().ok_or_else(|| { + anyhow!( + "no Config.Versions entry found in {}/main.go — declare your release, e.g. Versions: map[string]system.MigrationVersions{{\"v0.1.0\": {{App: \"0001\"}}}}.", + dir.display() + ) + }) +} + +/// Strip the SDK's `v` key prefix and validate canonical SemVer — the form +/// the platform stores and deploy sends on the wire. +fn canonical_version(raw: &str) -> Result { + let canonical = raw.strip_prefix('v').unwrap_or(raw); + if module_meta::parse_semver(canonical).is_none() { + return Err(anyhow!( + "version '{raw}' in main.go is not SemVer (expected e.g. v1.2.0 or v1.2.0-beta.1)" + )); + } + Ok(canonical.to_string()) +} + +/// Resolve the platform UUID by slug. main.go's Config.ID is the sanitized +/// `m` form, not the raw UUID the version endpoints take. +/// GET /v1/modules/{slug} is caller-scoped, so this doubles as an +/// ownership check. +fn get_owned_module( + client: &reqwest::blocking::Client, + apps_base: &str, + access_token: &str, + slug: &str, +) -> Result { + match api::get_module(client, apps_base, access_token, slug) { + Ok(Some(m)) => Ok(m), + Ok(None) => Err(anyhow!( + "module '{slug}' not found on the platform (run `mirrorstack module register` first)" + )), + Err(ApiError::Unauthenticated) => Err(session_expired()), + Err(e) => Err(e.into()), + } +} + +/// First `max_lines` non-empty changelog lines for the record summary, +/// with a trailing ellipsis when the section continues. +fn changelog_preview(body: &str, max_lines: usize) -> Vec { + let mut nonempty = body.lines().filter(|l| !l.trim().is_empty()); + let mut out: Vec = nonempty + .by_ref() + .take(max_lines) + .map(|l| l.trim().to_string()) + .collect(); + if nonempty.next().is_some() { + out.push("…".to_string()); + } + out +} + +/// Hints for the record step. `version_exists` never reaches here — deploy +/// treats it as "already recorded" and proceeds. +fn record_error_hint(code: &str) -> &'static str { + match code { + "version_invalid" => " (versions must be canonical SemVer, e.g. 1.2.0 or 1.2.0-beta.1)", + "changelog_too_large" => " (trim this version's CHANGELOG.md section to 16KB)", + "readme_too_large" => " (trim README.md to 64KB)", + _ => "", + } +} + +fn deploy_error_hint(code: &str) -> &'static str { + match code { + "not_found" => { + " (the version record vanished mid-deploy — re-run `mirrorstack module deploy`)" + } + "invoke_target_invalid" => { + " (expected a Lambda function name or full ARN, optional :qualifier, [A-Za-z0-9_-]{1,140})" + } + _ => "", + } +} + /// Parse `use` directives from go.work content (same logic as workspace.rs /// but returns raw strings since we don't need abs paths here). fn parse_go_work_use_dirs(body: &str) -> Vec { @@ -697,6 +1085,44 @@ mod tests { assert!(slug_error_hint("slug_taken").contains("--used")); } + #[test] + fn deploy_error_hint_for_known_codes() { + assert!(deploy_error_hint("not_found").contains("mirrorstack module deploy")); + assert!(deploy_error_hint("invoke_target_invalid").contains("ARN")); + assert_eq!(deploy_error_hint("something_else"), ""); + } + + #[test] + fn record_error_hint_for_known_codes() { + assert!(record_error_hint("version_invalid").contains("SemVer")); + assert!(record_error_hint("changelog_too_large").contains("CHANGELOG.md")); + assert!(record_error_hint("readme_too_large").contains("README.md")); + // `version_exists` is intercepted by deploy (already-recorded path), + // so the hint table deliberately has no entry for it. + assert_eq!(record_error_hint("version_exists"), ""); + assert_eq!(record_error_hint("something_else"), ""); + } + + #[test] + fn canonical_version_strips_v_prefix() { + assert_eq!(canonical_version("v0.1.0").unwrap(), "0.1.0"); + assert_eq!(canonical_version("1.2.3-beta.1").unwrap(), "1.2.3-beta.1"); + } + + #[test] + fn canonical_version_rejects_non_semver() { + for s in ["v1.0", "one.two.three", "v1.2.3.4", ""] { + assert!(canonical_version(s).is_err(), "expected {s:?} rejected"); + } + } + + #[test] + fn changelog_preview_truncates_with_ellipsis() { + let body = "- a\n\n- b\n- c\n- d\n"; + assert_eq!(changelog_preview(body, 3), vec!["- a", "- b", "- c", "…"]); + assert_eq!(changelog_preview("- only\n", 3), vec!["- only"]); + } + #[test] fn is_cwd_matches_cwd_variants() { assert!(is_cwd(Path::new("."))); diff --git a/src/commands/module/readme.rs b/src/commands/module/readme.rs new file mode 100644 index 0000000..85b2656 --- /dev/null +++ b/src/commands/module/readme.rs @@ -0,0 +1,232 @@ +//! README locale-map read for `module deploy`'s record step. +//! +//! README files at the module root are the module's long-form description. +//! Deploy reads them (when present) and records them on the version row so the +//! marketplace catalog can render the reader's locale. `README.md` is the +//! default; `README..md` (e.g. `README.zh-TW.md`) contributes a +//! locale-specific translation. Unlike CHANGELOG.md they are optional and +//! free-form — there is no lint. Missing files are not an error (nothing is +//! sent). Each value has its trailing whitespace trimmed and is capped at the +//! platform's byte limit, truncated on a UTF-8 char boundary. + +use std::collections::BTreeMap; +use std::path::Path; + +use anyhow::{Context, Result}; + +/// Server-side cap on each `module_versions.readme` value (64KB). Enforced +/// here so an oversized README is truncated client-side instead of +/// round-tripping a `readme_too_large` rejection. +pub(super) const MAX_README_BYTES: usize = 65_536; + +/// Result of scanning the module dir for README files. +pub(super) struct Readme { + /// Locale → trimmed, capped markdown. Key `default` is `README.md`; a + /// `` key is `README..md`. Empty when the module ships no + /// README (or every file held only whitespace). + pub map: BTreeMap, + /// Whether any value was truncated to fit [`MAX_README_BYTES`]. + pub truncated: bool, +} + +/// Scan `module_dir` for `README.md` (key `default`) and any +/// `README..md` (key ``), building a locale map. A dir with no +/// README files yields an empty map — READMEs are optional, unlike +/// CHANGELOG.md. +pub(super) fn read(module_dir: &Path) -> Result { + let mut map = BTreeMap::new(); + let mut truncated = false; + + let entries = match std::fs::read_dir(module_dir) { + Ok(entries) => entries, + // A missing module dir is treated the same as one with no READMEs — + // the caller's own preflight already resolved the module. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(Readme { map, truncated }); + } + Err(e) => return Err(e).with_context(|| format!("read dir {}", module_dir.display())), + }; + + for entry in entries { + let entry = entry.with_context(|| format!("read dir {}", module_dir.display()))?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + let Some(key) = readme_key(name) else { + continue; + }; + let path = entry.path(); + // `is_file` follows symlinks (matching the old single-file read) and + // skips a directory that happens to be named like a README. + if !path.is_file() { + continue; + } + let raw = + std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let (body, hit_cap) = cap(raw.trim_end()); + truncated |= hit_cap; + if !body.is_empty() { + map.insert(key, body); + } + } + + Ok(Readme { map, truncated }) +} + +/// Locale key for a README file name, or None when it isn't a README: +/// `README.md` → `default`; `README..md` → `` when `` is a +/// non-empty locale-ish token (`[A-Za-z0-9_-]`, matching the platform's key +/// validation). Anything else (e.g. `README.release.notes`) is ignored. +fn readme_key(file_name: &str) -> Option { + if file_name == "README.md" { + return Some("default".to_string()); + } + let tag = file_name.strip_prefix("README.")?.strip_suffix(".md")?; + if tag.is_empty() + || !tag + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return None; + } + Some(tag.to_string()) +} + +/// Cap `text` at [`MAX_README_BYTES`], truncating on the nearest UTF-8 char +/// boundary at or below the limit so a multi-byte character is never split. +/// Returns the (possibly truncated) body and whether truncation happened. +fn cap(text: &str) -> (String, bool) { + if text.len() <= MAX_README_BYTES { + return (text.to_string(), false); + } + let mut end = MAX_README_BYTES; + while !text.is_char_boundary(end) { + end -= 1; + } + (text[..end].to_string(), true) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_no_readme_is_empty() { + let tmp = tempfile::tempdir().unwrap(); + let r = read(tmp.path()).expect("ok"); + assert!(r.map.is_empty()); + assert!(!r.truncated); + } + + #[test] + fn read_missing_dir_is_empty() { + let tmp = tempfile::tempdir().unwrap(); + let r = read(&tmp.path().join("nope")).expect("ok"); + assert!(r.map.is_empty()); + assert!(!r.truncated); + } + + #[test] + fn read_default_trims_trailing_whitespace() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("README.md"), "# Media\n\nDocs.\n\n \n").unwrap(); + let r = read(tmp.path()).expect("ok"); + assert_eq!( + r.map.get("default").map(String::as_str), + Some("# Media\n\nDocs.") + ); + assert_eq!(r.map.len(), 1); + assert!(!r.truncated); + } + + #[test] + fn read_collects_locale_variants() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("README.md"), "# Media\n").unwrap(); + std::fs::write(tmp.path().join("README.zh-TW.md"), "# 媒體\n").unwrap(); + std::fs::write(tmp.path().join("README.en_US.md"), "# Media US\n").unwrap(); + let r = read(tmp.path()).expect("ok"); + assert_eq!(r.map.get("default").map(String::as_str), Some("# Media")); + assert_eq!(r.map.get("zh-TW").map(String::as_str), Some("# 媒體")); + assert_eq!(r.map.get("en_US").map(String::as_str), Some("# Media US")); + assert_eq!(r.map.len(), 3); + } + + #[test] + fn read_omits_empty_files() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("README.md"), "\n\n \n").unwrap(); + std::fs::write(tmp.path().join("README.fr.md"), "# Bonjour\n").unwrap(); + let r = read(tmp.path()).expect("ok"); + assert!(!r.map.contains_key("default")); + assert_eq!(r.map.get("fr").map(String::as_str), Some("# Bonjour")); + assert_eq!(r.map.len(), 1); + } + + #[test] + fn read_ignores_non_readme_files() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("README.md"), "# Media\n").unwrap(); + std::fs::write(tmp.path().join("CHANGELOG.md"), "# Changelog\n").unwrap(); + std::fs::write(tmp.path().join("README.notes.txt"), "not markdown\n").unwrap(); + std::fs::write(tmp.path().join("READMEER.md"), "nope\n").unwrap(); + let r = read(tmp.path()).expect("ok"); + assert_eq!(r.map.len(), 1); + assert!(r.map.contains_key("default")); + } + + #[test] + fn read_flags_truncation() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("README.md"), + "x".repeat(MAX_README_BYTES + 10), + ) + .unwrap(); + let r = read(tmp.path()).expect("ok"); + assert!(r.truncated); + assert_eq!( + r.map.get("default").map(String::len), + Some(MAX_README_BYTES) + ); + } + + #[test] + fn readme_key_maps_default_and_tags() { + assert_eq!(readme_key("README.md").as_deref(), Some("default")); + assert_eq!(readme_key("README.zh-TW.md").as_deref(), Some("zh-TW")); + assert_eq!(readme_key("README.en_US.md").as_deref(), Some("en_US")); + assert_eq!(readme_key("README.notes.txt"), None); + assert_eq!(readme_key("READMEER.md"), None); + assert_eq!(readme_key("README..md"), None); + // A multi-dot middle isn't a single locale-ish token. + assert_eq!(readme_key("README.zh.TW.md"), None); + } + + #[test] + fn cap_leaves_small_text_untouched() { + let (body, truncated) = cap("short"); + assert_eq!(body, "short"); + assert!(!truncated); + } + + #[test] + fn cap_truncates_oversized_text() { + let big = "x".repeat(MAX_README_BYTES + 10); + let (body, truncated) = cap(&big); + assert_eq!(body.len(), MAX_README_BYTES); + assert!(truncated); + } + + #[test] + fn cap_truncates_on_char_boundary() { + // End the buffer with a 2-byte char straddling the cap so a naive byte + // cut would split it; cap must back off to a char boundary. + let mut s = "a".repeat(MAX_README_BYTES - 1); + s.push('é'); // occupies bytes MAX-1 and MAX + s.push('é'); // pushes total length over the cap + let (body, truncated) = cap(&s); + assert!(truncated); + assert!(body.len() <= MAX_README_BYTES); + assert!(s.is_char_boundary(body.len())); + } +} diff --git a/src/commands/module/scaffold.rs b/src/commands/module/scaffold.rs index 49d1aea..116d62b 100644 --- a/src/commands/module/scaffold.rs +++ b/src/commands/module/scaffold.rs @@ -143,7 +143,7 @@ mod tests { // Slug field is the SDK v0.2.0 catalog handle. The scaffolded // module needs it set so the manifest carries it from day one; // missing-slug behavior (dev-only mode) is for hand-written - // pre-publish modules, not the CLI scaffold. + // never-deployed modules, not the CLI scaffold. let out = render(MAIN_GO, &ins("oauth", "OAuth")); assert!( out.contains(r#"Slug: "oauth""#), @@ -153,9 +153,9 @@ mod tests { #[test] fn render_versions_default_is_dev_prerelease() { - // Pre-1.0 dev builds use `v0.1.0-dev` so `mirrorstack publish` can - // refuse `-dev` versions in prod by convention. Promotion - // happens at publish time. See docs/module-identity-and-storage-prefix.md. + // Pre-1.0 dev builds use `v0.1.0-dev` so `mirrorstack module deploy` can + // refuse `-dev` versions in CI (--yes) by convention. Promotion + // happens at deploy time. See docs/module-identity-and-storage-prefix.md. let out = render(MAIN_GO, &ins("media", "Media")); assert!( out.contains(r#""v0.1.0-dev": {App: "0001"}"#), diff --git a/templates/module/main.go.tmpl b/templates/module/main.go.tmpl index 1296674..95e71d6 100644 --- a/templates/module/main.go.tmpl +++ b/templates/module/main.go.tmpl @@ -31,15 +31,15 @@ func main() { ID: "__MS_MODULE_ID__", // Slug is the catalog handle (e.g. "oauth"). Mutable via catalog UI; // the storage prefix at install time is __. Renames - // force a new published version with auto-generated rename migrations. + // force a new recorded version with auto-generated rename migrations. Slug: "__MS_SLUG__", Name: "__MS_NAME__", Icon: "extension", SQL: sqlFS, Versions: map[string]system.MigrationVersions{ // `-dev` prerelease tag distinguishes "iterating locally" from - // "real release". `mirrorstack publish` prompts to promote it - // before shipping; CI / prod refuses `-dev` versions. + // "real release". `mirrorstack module deploy` prompts to promote + // before shipping; CI (--yes) refuses `-dev` versions. "v0.1.0-dev": {App: "0001"}, }, }); err != nil {