diff --git a/.changes/unreleased/Added-20260819-adapter-doctor-checks.yaml b/.changes/unreleased/Added-20260819-adapter-doctor-checks.yaml new file mode 100644 index 0000000..6474470 --- /dev/null +++ b/.changes/unreleased/Added-20260819-adapter-doctor-checks.yaml @@ -0,0 +1,7 @@ +component: doctor +kind: Added +body: |- + **`doctor` checks an adapter workspace's own invariants instead of Gleam's.** Every member manifest must declare a version that parses as semver (an error), and a name matching its directory (a warning — nothing downstream requires them to agree, and repositories in the wild do drift here). + + The checks with nothing to read are dropped: `manifest.toml` lock drift, shared external dependency agreement, and the `gleam`-on-PATH toolchain pin. The `checked:` preamble now names the checks that actually ran rather than a fixed list. +time: 2026-08-19T09:02:00.000000-07:00 diff --git a/.changes/unreleased/Added-20260819-adapter-manifest-seam.yaml b/.changes/unreleased/Added-20260819-adapter-manifest-seam.yaml new file mode 100644 index 0000000..a19d83b --- /dev/null +++ b/.changes/unreleased/Added-20260819-adapter-manifest-seam.yaml @@ -0,0 +1,9 @@ +component: trellis +kind: Added +body: |- + **`[tools.trellis.adapter]` lets a workspace's members be something other than Gleam packages.** One key is required — `manifest`, the member manifest's path relative to the member directory, whose presence marks a member the way a `gleam.toml` does otherwise, and whose extension picks the format (`.toml`, `.json`, `.yaml`, `.yml`). `name` and `version` are dotted field paths into that manifest, defaulting to `name` and `version`. Built for repositories distributed by git ref rather than a registry: Claude Code plugin marketplaces, APM monorepos, AI skill collections. + + Trellis also accepts a **`trellis.toml`** at the workspace root carrying the same `[tools.trellis]` table, since an adapter workspace usually has no `gleam.toml` for it to live in. Root discovery walks up to the first directory whose `trellis.toml` or `gleam.toml` carries the table, preferring `trellis.toml`; carrying it in both at once is an error. + + The dependency graph is flat under an adapter — no dependency edges are declared, so topological order is member order and a workspace dependency never ripple-bumps. `publish.lifecycle` defaults to `git_only` and `hex` anywhere under an adapter is a configuration error, because there is no registry to publish to. +time: 2026-08-19T09:00:00.000000-07:00 diff --git a/.changes/unreleased/Added-20260819-adapter-version-writeback.yaml b/.changes/unreleased/Added-20260819-adapter-version-writeback.yaml new file mode 100644 index 0000000..a4c1100 --- /dev/null +++ b/.changes/unreleased/Added-20260819-adapter-version-writeback.yaml @@ -0,0 +1,7 @@ +component: version +kind: Added +body: |- + **`version apply` writes the bump into the field `[tools.trellis.adapter].version` names.** The edit is surgical for JSON and YAML as it already was for TOML: only that scalar changes, so a hand-maintained `plugin.json` or `apm.yml` keeps its comments, key order, indentation, and quote style byte for byte. A field path that resolves to nothing, or to a mapping, sequence, or non-string, is an error — the bump never inserts a key. + + The `manifest.toml` lockfile patch is skipped in an adapter workspace, which has no Gleam lock to keep in step. +time: 2026-08-19T09:01:00.000000-07:00 diff --git a/.changes/unreleased/Changed-20260819-adapter-refusals.yaml b/.changes/unreleased/Changed-20260819-adapter-refusals.yaml new file mode 100644 index 0000000..2ce6087 --- /dev/null +++ b/.changes/unreleased/Changed-20260819-adapter-refusals.yaml @@ -0,0 +1,7 @@ +component: run +kind: Changed +body: |- + **The built-in verbs are refused in an adapter workspace, and name the tasks you have declared.** `build`, `test`, `check`, `docs`, `deps`, `format`, and `clean` all shell out to `gleam`, which an adapter workspace's members are not. Declare what you need under `[tools.trellis.tasks]` — a custom task with a built-in's name still overrides it, and `trellis exec` is unaffected. + + `publish`, `lockfile refresh`, and `new` are refused for the same reason. +time: 2026-08-19T09:03:00.000000-07:00 diff --git a/.changes/unreleased/Fixed-20260819-init-sees-trellis-toml.yaml b/.changes/unreleased/Fixed-20260819-init-sees-trellis-toml.yaml new file mode 100644 index 0000000..6bfb524 --- /dev/null +++ b/.changes/unreleased/Fixed-20260819-init-sees-trellis-toml.yaml @@ -0,0 +1,5 @@ +component: init +kind: Fixed +body: |- + **`init` refuses a repository already configured by a `trellis.toml`.** It only looked for a `[tools.trellis]` table in `gleam.toml` files, so in a workspace configured the new way it wrote a second config home — a state every later command then rejects as ambiguous. Member manifests are checked for both spellings too. +time: 2026-08-19T09:04:00.000000-07:00 diff --git a/Cargo.lock b/Cargo.lock index ef44293..46a9c9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "assert_cmd" version = "2.2.2" @@ -1050,6 +1056,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "saphyr-parser" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b1b76852c5643f558e7347e4de549474c3ecb7154295a189f4668428c1b453" +dependencies = [ + "arraydeque", + "thiserror", +] + [[package]] name = "semver" version = "1.0.28" @@ -1212,6 +1228,26 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "time" version = "0.3.45" @@ -1335,6 +1371,7 @@ dependencies = [ "insta", "minijinja", "predicates", + "saphyr-parser", "semver", "serde", "serde_ignored", diff --git a/Cargo.toml b/Cargo.toml index 9fdbbc8..bd49cae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ toml = "1.1.2" toml_edit = "0.25.12" ureq = { version = "3.3.0", features = ["json"] } serde_ignored = "0.1.14" +saphyr-parser = "0.0.12" [dev-dependencies] assert_cmd = "2.2.2" diff --git a/README.md b/README.md index b744854..a4915f6 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,13 @@ version, and path dependencies. The dependency graph — topological order, publish order, change impact, path-dep rewrite maps — is computed, never declared. +Gleam is the native, batteries-included ecosystem. For a repository whose +packages are not Gleam packages — a Claude Code plugin marketplace, an APM +monorepo, anything distributed by git ref rather than a registry — a +[`[tools.trellis.adapter]`](#manifest-adapter) table redefines which file marks +a member and where its name and version live. Everything downstream is the same +code. + See [docs/DESIGN.md](docs/DESIGN.md) for the full design. ## Status @@ -171,11 +178,45 @@ literal path remains included even when Git ignores it. `[tools.trellis.exclude] is a separate post-discovery filter: task and `@release` exclusions do not control traversal. +### Manifest adapter + +`[tools.trellis.adapter]` redefines the manifest seam for a workspace whose +members are not Gleam packages. One key is required — `manifest`, the member +manifest's path relative to the member directory, whose presence marks a member +and whose extension (`.toml`, `.json`, `.yaml`, `.yml`) picks the format: + +```toml +# trellis.toml at the repo root — an adapter workspace usually has no +# gleam.toml for the table to live in, so trellis accepts this file too. +# Root discovery prefers it; carrying the table in both is an error. +[tools.trellis] +members = ["plugins/*"] + +[tools.trellis.adapter] +manifest = ".claude-plugin/plugin.json" +# name = "name" # dotted field paths, defaults shown +# version = "version" # this is the field `version apply` rewrites + +[tools.trellis.publish] +package_tags = ["exact", "major"] +``` + +The bump is surgical: only the field `version` names is rewritten, so comments, +key order, and formatting survive byte for byte. The changelog engine, +`version`, `tag`, GitHub Releases, `release pr`, and `ci` are unchanged. + +The graph is flat — an adapter declares no dependency edges, so nothing ripple- +bumps. `publish.lifecycle` defaults to `git_only` and `hex` is a configuration +error; `publish`, `lockfile refresh`, `new`, and `run`'s built-in Gleam verbs +are refused. `doctor` drops the `manifest.toml`, shared-dependency, and +toolchain checks, and adds that every member manifest declares a semver version +and a name matching its directory. + ## Commands Every command works from anywhere inside the workspace (the root is found by -walking up to the first `gleam.toml` with a `[tools.trellis]` table, like -`git` or `cargo` — member manifests along the way are skipped). Without a +walking up to the first `trellis.toml` or `gleam.toml` with a `[tools.trellis]` +table, like `git` or `cargo` — member manifests along the way are skipped). Without a `[tools.trellis]` table anywhere, the git repository root is the workspace root and members are auto-discovered. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 09d180a..f565210 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -294,6 +294,58 @@ Notably absent, because derived: package lists, dependency order, per-package changelog wiring, version-file maps, path-dep rewrite maps, tag→package mappings. +### 4.2 The manifest seam + +Trellis reads exactly four things out of a member's manifest: + +1. **Membership** — the file's presence is what makes a directory a member. +2. **Identity** — its name. +3. **Version** — the field a bump rewrites. +4. **Dependency edges** — path deps, which become the graph. + +Everything else trellis does is computed from those four, and is therefore +language-agnostic: the changelog engine, `version plan`/`apply`, exact and +series tags, GitHub Releases, `release pr`, the scheduler, `ci matrix` and +`ci outputs`. The `git_only` lifecycle already *is* the publishing model for a +registry-less package type — changelog → version → tag → GitHub Release. + +`[tools.trellis.adapter]` redefines the first three for a different manifest +format, and deliberately leaves the fourth undefined: + +```toml +[tools.trellis.adapter] +manifest = ".claude-plugin/plugin.json" # membership; extension picks format +name = "name" # identity, dotted field path +version = "version" # bump target, dotted field path +``` + +No `deps` key means a flat graph, and every consumer of the graph degrades +cleanly rather than specially: topological order is member order, ripple bumps +never fire, and the path-dep rewrite is unreachable. That is the whole reason +the adapter is four keys rather than a subsystem — declaring dependency edges +in an arbitrary manifest format is the expensive half, and no motivating +repository needs it yet. + +Two consequences follow from the seam rather than from configuration. A +workspace whose members are not Gleam packages usually has no `gleam.toml` for +the `[tools.trellis]` table to live in, so a root `trellis.toml` is a second +config home, preferred over `gleam.toml` when both exist and an error when both +carry the table. And the lifecycle ladder tops out one rung lower: `hex` under +an adapter is a configuration error, not a runtime refusal, so it is caught +once at load rather than at each of `publish`'s four gates. + +The write side is where the seam costs something real. A `gleam.toml` bump has +always been surgical — `toml_edit` with the original value's decor cloned onto +the replacement — because a manifest is a file its owner reads. That promise +has to hold for JSON and YAML too, and neither has a `toml_edit`. `src/manifest.rs` +gets it from the fact that YAML 1.2 is a strict superset of JSON: one +`saphyr-parser` event walk locates the target scalar's span in both formats, +and the edit is a byte splice. Two of that parser's guarantees do not survive +contact — `Marker::index` is a char index despite its doc comment, and a quoted +scalar's `span.end` in block context runs to end-of-line, comment included — so +only the start marker and the scalar style are trusted, and the extent is +re-lexed from there. + ## 5. Command surface ### Introspection diff --git a/src/changelog.rs b/src/changelog.rs index e0e041c..6e69408 100644 --- a/src/changelog.rs +++ b/src/changelog.rs @@ -711,17 +711,6 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) { (if month <= 2 { year + 1 } else { year }, month, day) } -pub fn render_manifest_version(text: &str, next: &semver::Version) -> Result { - let mut doc: toml_edit::DocumentMut = text.parse().context("failed to parse gleam.toml")?; - let Some(value) = doc.get_mut("version").and_then(|item| item.as_value_mut()) else { - bail!("gleam.toml has no version field"); - }; - let mut replacement = toml_edit::Value::from(next.to_string()); - *replacement.decor_mut() = value.decor().clone(); - *value = replacement; - Ok(doc.to_string()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/commands/ci.rs b/src/commands/ci.rs index cc1760b..62b18b4 100644 --- a/src/commands/ci.rs +++ b/src/commands/ci.rs @@ -61,7 +61,7 @@ pub fn outputs(workspace: &Workspace) -> Result<()> { .members .iter() .filter(|m| m.releasable()) - .map(|m| format!("{}/gleam.toml", m.rel_path)) + .map(|m| format!("{}/{}", m.rel_path, workspace.manifest_rel())) .collect(); let tags: Vec = workspace .members diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 73225ad..1f86ee8 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -177,12 +177,13 @@ impl Report { /// Load the workspace and run every check, collecting findings and the fixes /// that would remediate the mechanical ones. No output, no side effects. -fn inspect(root: &Path) -> Result { +fn inspect(root: &Path) -> Result<(Report, Option)> { let (workspace, diagnostics) = Workspace::load_with_diagnostics(root)?; let mut report = Report { findings: diagnostics.findings, ..Report::default() }; + let mut adapter_manifest = None; if let Some(workspace) = &workspace { report.configless = workspace.configless; @@ -195,41 +196,120 @@ fn inspect(root: &Path) -> Result { lifecycle: member.lifecycle, }) .collect(); + adapter_manifest = workspace.adapter().map(|adapter| adapter.manifest.clone()); + // The checks reading a `manifest.toml`, a Gleam dependency table, or + // the gleam binary have nothing to look at in an adapter workspace; + // `check_adapter` takes their place. The rest run either way. + let gleam = adapter_manifest.is_none(); check_exclusions(workspace, &mut report); check_tag_collisions(workspace, &mut report); - check_lockfiles(workspace, &mut report); + if gleam { + check_lockfiles(workspace, &mut report); + } check_changelogs(workspace, &mut report); check_fragments(workspace, &mut report); - check_shared_dependencies(workspace, &mut report); - check_tool_versions(workspace, &mut report); + if gleam { + check_shared_dependencies(workspace, &mut report); + check_tool_versions(workspace, &mut report); + } else { + check_adapter(workspace, &mut report); + } + } + Ok((report, adapter_manifest)) +} + +/// The adapter's own claims about each member manifest, in place of the checks +/// that read a `gleam.toml`. +/// +/// Missing, unparseable, and nameless manifests are already errors from +/// [`Workspace::load_with_diagnostics`] — a member that fails any of those is +/// not in `members` at all — so what is left is what loading accepted but a +/// release would trip over. +fn check_adapter(workspace: &Workspace, report: &mut Report) { + let manifest_rel = workspace.manifest_rel(); + for member in &workspace.members { + let at = format!("{}/{manifest_rel}", member.rel_path); + if semver::Version::parse(member.version()).is_err() { + report.push( + Finding::error( + Check::PackageVersion, + format!( + "package `{}` version `{}` is not valid semver", + member.name, + member.version() + ), + ) + .at(&at) + .in_package(member.name.clone()), + ); + } + // Members are found by globbing directories, so a name that disagrees + // with its directory makes `--package` and the tag namespace read + // wrong. A warning, not an error: nothing downstream requires them to + // match, and repositories in the wild do drift here. + let dir_name = member + .rel_path + .rsplit('/') + .next() + .unwrap_or(&member.rel_path); + if !member.rel_path.is_empty() && dir_name != member.name { + report.push( + Finding::warning( + Check::PackageManifest, + format!( + "package `{}` lives in `{}`; the manifest name and the directory \ + disagree", + member.name, member.rel_path + ), + ) + .at(&at) + .in_package(member.name.clone()), + ); + } } - Ok(report) } /// Returns true when the workspace is healthy (warnings allowed). pub fn run(root: &Path, options: &DoctorOptions) -> Result { let text = options.format.is_text(); + // Inspecting first so the preamble can name the checks that actually ran — + // it writes nothing, so the output order is unchanged. + let (mut report, adapter_manifest) = inspect(root)?; if text { - let checked = [ - "member globs resolve and every package has a parseable gleam.toml", - "path dependencies stay inside the workspace; graph is acyclic", - "task exclusion globs match members; no package depends on one unavailable at its release lifecycle", - "tag format produces a unique tag per releasable package", - "manifest.toml locked versions match workspace-internal gleam.toml versions", - "each releasable package's version is not behind its CHANGELOG", - "unreleased changelog fragments parse and reference valid packages, kinds, and categories", - "[tools.trellis] carries no unrecognized or deprecated keys", - "packages agree on the external dependencies they share", - "gleam on PATH matches the .tool-versions pin (advisory)", - ]; - for check in checked { + let manifest = adapter_manifest.as_deref().unwrap_or("gleam.toml"); + let mut checked = vec![format!( + "member globs resolve and every package has a parseable {manifest}" + )]; + if adapter_manifest.is_none() { + checked + .push("path dependencies stay inside the workspace; graph is acyclic".to_string()); + checked.push("task exclusion globs match members; no package depends on one unavailable at its release lifecycle".to_string()); + } else { + checked.push("task exclusion globs match members".to_string()); + } + checked.push("tag format produces a unique tag per releasable package".to_string()); + if adapter_manifest.is_none() { + checked.push(format!( + "manifest.toml locked versions match workspace-internal {manifest} versions" + )); + } + checked.push("each releasable package's version is not behind its CHANGELOG".to_string()); + checked.push("unreleased changelog fragments parse and reference valid packages, kinds, and categories".to_string()); + checked.push("[tools.trellis] carries no unrecognized or deprecated keys".to_string()); + if adapter_manifest.is_none() { + checked.push("packages agree on the external dependencies they share".to_string()); + checked.push("gleam on PATH matches the .tool-versions pin (advisory)".to_string()); + } else { + checked.push(format!( + "every {manifest} declares a semver version, and a name matching its directory" + )); + } + for check in &checked { crate::status!("{}", crate::term::dim(&format!("checked: {check}"))); } crate::status!(); } - let mut report = inspect(root)?; - // --dry-run only previews; it never writes, so state (and exit code) is // identical to a plain run — a fixable error still fails, keeping CI honest. if options.dry_run { @@ -261,7 +341,7 @@ pub fn run(root: &Path, options: &DoctorOptions) -> Result { // Moved out before the re-inspect, which by design no longer reports // them — this is the only remaining record of what was written. applied = std::mem::take(&mut report.fixes); - report = inspect(root)?; + report = inspect(root)?.0; } if text { @@ -570,7 +650,7 @@ fn check_exclusions(workspace: &Workspace, report: &mut Report) { dep.lifecycle.key(), ), ) - .at(format!("{}/gleam.toml", member.rel_path)) + .at(format!("{}/{}", member.rel_path, workspace.manifest_rel())) .in_package(member.name.clone()), ); } @@ -597,14 +677,14 @@ fn check_member_glob(workspace: &Workspace, label: &str, pattern: &str, report: Check::ExclusionGlob, format!("{label} `{pattern}` matches no member (typo?)"), ) - .at(crate::workspace::GLEAM_TOML), + .at(workspace.config_rel), ), None => report.push( Finding::error( Check::ExclusionGlob, format!("{label} `{pattern}` is invalid"), ) - .at(crate::workspace::GLEAM_TOML), + .at(workspace.config_rel), ), } } @@ -628,6 +708,7 @@ fn check_tag_collisions(workspace: &Workspace, report: &mut Report) { tag: String, owner: String, member: &crate::workspace::Member, + config_rel: &str, ) { if let Some(other) = seen.get(&tag) { report.push( @@ -635,7 +716,7 @@ fn check_tag_collisions(workspace: &Workspace, report: &mut Report) { Check::TagCollision, format!("tag collision: {other} and {owner} both produce tag `{tag}`"), ) - .at(crate::workspace::GLEAM_TOML) + .at(config_rel) .in_package(&member.name), ); } else { @@ -654,6 +735,7 @@ fn check_tag_collisions(workspace: &Workspace, report: &mut Report) { tag, format!("package `{}` exact tag", member.name), member, + workspace.config_rel, ); } } @@ -698,7 +780,7 @@ fn check_tag_collisions(workspace: &Workspace, report: &mut Report) { workspace.config.publish.series_tag_format, ), ) - .at(crate::workspace::GLEAM_TOML), + .at(workspace.config_rel), ); } @@ -723,6 +805,7 @@ fn check_tag_collisions(workspace: &Workspace, report: &mut Report) { tag, format!("package `{}` series tag", member.name), member, + workspace.config_rel, ); } } @@ -746,7 +829,7 @@ fn check_tag_collisions(workspace: &Workspace, report: &mut Report) { anchor.name ), ) - .at(crate::workspace::GLEAM_TOML) + .at(workspace.config_rel) .in_package(&anchor.name), ); } @@ -889,7 +972,7 @@ fn check_changelogs(workspace: &Workspace, report: &mut Report) { member.version() ), ) - .at(format!("{}/gleam.toml", member.rel_path)) + .at(format!("{}/{}", member.rel_path, workspace.manifest_rel())) .in_package(member.name.clone()), ); continue; @@ -903,11 +986,13 @@ fn check_changelogs(workspace: &Workspace, report: &mut Report) { Finding::error( Check::ChangelogBehind, format!( - "package `{}` gleam.toml version {} is behind its CHANGELOG ({latest})", - member.name, current + "package `{}` {} version {} is behind its CHANGELOG ({latest})", + member.name, + workspace.manifest_rel(), + current ), ) - .at(format!("{}/gleam.toml", member.rel_path)) + .at(format!("{}/{}", member.rel_path, workspace.manifest_rel())) .in_package(member.name.clone()), ); } diff --git a/src/commands/init.rs b/src/commands/init.rs index 093bc80..8041469 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -8,7 +8,7 @@ use crate::config::has_trellis_table; use crate::git; -use crate::workspace::{self, GLEAM_TOML}; +use crate::workspace::{self, CONFIG_HOMES, GLEAM_TOML, TRELLIS_TOML}; use anyhow::{Context, Result, bail}; use std::path::{Path, PathBuf}; @@ -85,25 +85,28 @@ fn refuse_if_already_a_workspace(root: &Path) -> Result<()> { ); } for member in workspace::discovered_member_paths(root) { - let path = root.join(&member).join(GLEAM_TOML); - if reads_trellis_table(&path) { - bail!( - "{} has a [tools.trellis] table; a member manifest cannot carry one \ - (it would hijack workspace-root discovery). Remove it, then rerun \ - `trellis init`", - path.display() - ); + for name in [GLEAM_TOML, TRELLIS_TOML] { + let path = root.join(&member).join(name); + if reads_trellis_table(&path) { + bail!( + "{} has a [tools.trellis] table; a member manifest cannot carry one \ + (it would hijack workspace-root discovery). Remove it, then rerun \ + `trellis init`", + path.display() + ); + } } } Ok(()) } -/// The nearest `gleam.toml` at or above `root` carrying the table. Ancestors +/// The nearest config home at or above `root` carrying the table. Ancestors /// count: initializing inside an existing workspace would nest one workspace in -/// another, which trellis has no notion of. +/// another, which trellis has no notion of. Both homes are checked, so `init` +/// cannot write a second table beside a `trellis.toml` that already has one. fn trellis_table_at_or_above(root: &Path) -> Option { root.ancestors() - .map(|dir| dir.join(GLEAM_TOML)) + .flat_map(|dir| CONFIG_HOMES.map(|name| dir.join(name))) .find(|manifest| reads_trellis_table(manifest)) } diff --git a/src/commands/lockfile.rs b/src/commands/lockfile.rs index e78504c..a744f5c 100644 --- a/src/commands/lockfile.rs +++ b/src/commands/lockfile.rs @@ -9,6 +9,7 @@ use anyhow::{Context, Result, bail}; use std::process::Command; pub fn refresh(workspace: &Workspace, package: Option<&str>) -> Result { + workspace.refuse_under_adapter("lockfile refresh")?; let targets: Vec = match package { Some(name) => { let idx = workspace diff --git a/src/commands/new.rs b/src/commands/new.rs index 4681363..a4d3b14 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -19,6 +19,7 @@ pub struct NewOptions { } pub fn run(workspace: &Workspace, options: &NewOptions) -> Result<()> { + workspace.refuse_under_adapter("new")?; if options.template != "lib" { bail!("unknown template `{}` (available: lib)", options.template); } diff --git a/src/commands/publish.rs b/src/commands/publish.rs index 7d858ff..893423d 100644 --- a/src/commands/publish.rs +++ b/src/commands/publish.rs @@ -34,6 +34,7 @@ pub struct PublishOptions { } pub fn run(workspace: &Workspace, options: &PublishOptions) -> Result { + workspace.refuse_under_adapter("publish")?; let targets: Vec = match &options.selector { Selector::Package(name) => { let idx = workspace diff --git a/src/commands/run.rs b/src/commands/run.rs index d4a914c..b8c1e4e 100644 --- a/src/commands/run.rs +++ b/src/commands/run.rs @@ -115,6 +115,25 @@ fn expand_targets(target: Option) -> Vec> { } } +/// ` (declared: a, b)`, or nothing when no custom tasks exist — the tail of +/// every message that has just refused a task name. +fn declared_tasks(workspace: &Workspace) -> String { + if workspace.config.tasks.is_empty() { + String::new() + } else { + format!( + " (declared: {})", + workspace + .config + .tasks + .keys() + .cloned() + .collect::>() + .join(", ") + ) + } +} + fn commands_for( workspace: &Workspace, options: &TaskOptions, @@ -133,6 +152,19 @@ fn commands_for( return Ok(commands); } + // Built-in verbs all shell out to `gleam`, which an adapter workspace's + // members are not. A custom task with the same name is honoured above, so + // reaching here means none was declared. + if let Some(adapter) = workspace.adapter() { + bail!( + "`{}` is a built-in Gleam task, and this workspace's members are `{}` manifests; \ + declare a command for it under [tools.trellis.tasks]{}", + options.task, + adapter.manifest, + declared_tasks(workspace) + ); + } + let targets = expand_targets(options.target); let commands = match options.task.as_str() { "build" => targeted(&["build"], &targets, options.strict, package_dir), @@ -151,20 +183,7 @@ fn commands_for( other => bail!( "unknown task `{other}`; built-ins: {}. Custom tasks are declared under [tools.trellis.tasks] in the root gleam.toml{}", BUILTIN_TASKS.join(", "), - if workspace.config.tasks.is_empty() { - String::new() - } else { - format!( - " (declared: {})", - workspace - .config - .tasks - .keys() - .cloned() - .collect::>() - .join(", ") - ) - } + declared_tasks(workspace) ), }; Ok(commands) diff --git a/src/commands/tag.rs b/src/commands/tag.rs index be5d77b..456355a 100644 --- a/src/commands/tag.rs +++ b/src/commands/tag.rs @@ -228,22 +228,28 @@ fn manifest_version_at_revision( anchor: &crate::workspace::Member, revision: &str, ) -> Result { + let manifest_rel = workspace.manifest_rel(); let manifest = if anchor.rel_path == "." { - crate::workspace::GLEAM_TOML.to_string() + manifest_rel.to_string() } else { - format!("{}/{}", anchor.rel_path, crate::workspace::GLEAM_TOML) + format!("{}/{manifest_rel}", anchor.rel_path) }; let object = format!("{revision}:{manifest}"); let text = git_stdout(&workspace.root, &["show", &object]).with_context(|| { format!("cannot read repository tag anchor manifest `{manifest}` at revision `{revision}`") })?; - GleamManifest::parse(&text) - .with_context(|| { - format!( - "cannot parse repository tag anchor manifest `{manifest}` at revision `{revision}`" - ) + let parsed = || -> Result { + Ok(match workspace.adapter() { + Some(adapter) => { + crate::manifest::read_string(&text, adapter.format()?, &adapter.version)? + .with_context(|| format!("no `{}` field", adapter.version))? + } + None => GleamManifest::parse(&text)?.version, }) - .map(|manifest| manifest.version) + }; + parsed().with_context(|| { + format!("cannot parse repository tag anchor manifest `{manifest}` at revision `{revision}`") + }) } pub fn plan(workspace: &Workspace, json: bool) -> Result<()> { diff --git a/src/commands/version.rs b/src/commands/version.rs index 0ec2c56..8129c8d 100644 --- a/src/commands/version.rs +++ b/src/commands/version.rs @@ -307,11 +307,24 @@ pub fn apply(workspace: &Workspace, overrides: &Overrides, json: bool) -> Result adoption.as_ref(), ) .with_context(|| format!("failed to merge `{}`", entry.name))?; - let manifest_path = member.path.join("gleam.toml"); + let manifest_path = member.path.join(workspace.manifest_rel()); let manifest = std::fs::read_to_string(&manifest_path) .with_context(|| format!("failed to read {}", manifest_path.display()))?; - let manifest = changelog::render_manifest_version(&manifest, &next) - .with_context(|| format!("failed to bump `{}`", entry.name))?; + // The adapter's version path, or a Gleam manifest's top-level + // `version`. Either way the edit is surgical: nothing else in the file + // moves, so a hand-maintained manifest keeps its comments and layout. + let (format, field) = match workspace.adapter() { + Some(adapter) => (adapter.format()?, adapter.version.as_str()), + None => (crate::manifest::Format::Toml, "version"), + }; + let manifest = crate::manifest::write_string(&manifest, format, field, &next.to_string()) + .with_context(|| { + format!( + "failed to bump `{}` in {}", + entry.name, + manifest_path.display() + ) + })?; prepared_versions.push(PreparedVersion { name: entry.name.clone(), next, @@ -332,8 +345,14 @@ pub fn apply(workspace: &Workspace, overrides: &Overrides, json: bool) -> Result versions.insert(entry.name.clone(), entry.next.clone()); } + // A Gleam concern only: an adapter workspace has no `manifest.toml` lock + // to keep in step with the bump. let mut prepared_lockfiles = Vec::new(); - for member in &workspace.members { + for member in workspace + .members + .iter() + .filter(|_| workspace.adapter().is_none()) + { let path = member.path.join("manifest.toml"); if !path.is_file() { continue; diff --git a/src/config.rs b/src/config.rs index a98a11c..ee60d7a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -42,6 +42,9 @@ pub struct ConfigFile { pub exclude: BTreeMap>, #[serde(default)] pub tasks: BTreeMap, + /// Redefines the manifest seam so members need not be Gleam packages. + /// Absent — the overwhelmingly common case — means `gleam.toml`. + pub adapter: Option, #[serde(default)] pub publish: PublishConfig, #[serde(default)] @@ -49,7 +52,7 @@ pub struct ConfigFile { #[serde(default)] pub doctor: DoctorConfig, /// Keys under `[tools.trellis]` that no field claimed. Collected rather - /// than deserialized — see [`ConfigFile::from_gleam_toml`]. + /// than deserialized — see [`ConfigFile::from_toml`]. #[serde(skip)] pub unknown_keys: Vec, /// Keys spelled in the pre-0.8 kebab-case style. Accepted, then reported — @@ -205,6 +208,56 @@ pub fn has_trellis_table(document: &toml::Value) -> bool { .is_some_and(toml::Value::is_table) } +/// `[tools.trellis.adapter]`: the manifest seam, for a workspace whose members +/// are not Gleam packages. +/// +/// Trellis derives four things from a member's manifest — that the directory is +/// a member at all, its name, its version, and its dependency edges. This table +/// redefines the first three for a different manifest format; the fourth is +/// deliberately not configurable in this release, so an adapter workspace has a +/// flat dependency graph (topological order is member order, and a ripple bump +/// never fires). Everything downstream — changelog, `version`, `tag`, +/// `release pr`, `ci` — is unchanged. +/// +/// Registry publishing is not: an adapter workspace tops out at the `git_only` +/// lifecycle, which is why [`LifecycleConfig::default`] flips to `git_only` +/// when this table is present. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct AdapterConfig { + /// Path to the member's manifest, relative to the member directory. Its + /// presence is what marks a directory as a member, the way a `gleam.toml` + /// does otherwise, and its extension picks the format + /// ([`crate::manifest::Format`]) — there is no format key. + pub manifest: String, + /// Dot-separated path to the member's name within the manifest. + #[serde(default = "default_adapter_name")] + pub name: String, + /// Dot-separated path to the member's version within the manifest. This is + /// the field `trellis version apply` rewrites. + #[serde(default = "default_adapter_version")] + pub version: String, +} + +impl AdapterConfig { + /// The manifest's basename, for the git pathspec discovery uses. + pub fn manifest_file_name(&self) -> &str { + self.manifest.rsplit('/').next().unwrap_or(&self.manifest) + } + + pub fn format(&self) -> Result { + crate::manifest::Format::from_path(&self.manifest) + } +} + +fn default_adapter_name() -> String { + "name".to_string() +} + +fn default_adapter_version() -> String { + "version".to_string() +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "snake_case")] pub struct TaskConfig { @@ -700,17 +753,30 @@ impl ConfigFile { pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path) .with_context(|| format!("failed to read {}", path.display()))?; - Self::from_gleam_toml(&text).with_context(|| format!("in {}", path.display())) + Self::from_toml(&text).with_context(|| format!("in {}", path.display())) } - pub fn from_gleam_toml(text: &str) -> Result { - let document: toml::Value = toml::from_str(text).context("failed to parse gleam.toml")?; + pub fn from_toml(text: &str) -> Result { + let document: toml::Value = toml::from_str(text).context("failed to parse TOML")?; let Some(trellis) = document.get("tools").and_then(|tools| tools.get("trellis")) else { - bail!("gleam.toml has no [tools.trellis] table"); + bail!("no [tools.trellis] table"); }; let (mut config, ignored) = deserialize_collecting_unknown(trellis)?; config.deprecated_keys = collect_deprecated_keys(trellis, &ignored); config.unknown_keys = ignored; + // An adapter workspace has no registry to publish to, so the ladder + // tops out one rung lower. Only the unwritten default moves; an + // explicit `workspace` still means `workspace`, and an explicit `hex` + // is rejected in `validate` rather than silently downgraded. + if config.adapter.is_some() + && trellis + .get("publish") + .and_then(|publish| publish.get("lifecycle")) + .and_then(|lifecycle| lifecycle.get("default")) + .is_none() + { + config.publish.lifecycle.default = ReleaseLifecycle::GitOnly; + } config.validate()?; Ok(config) } @@ -722,6 +788,7 @@ impl ConfigFile { members: None, exclude: BTreeMap::new(), tasks: BTreeMap::new(), + adapter: None, publish: PublishConfig::default(), changelog: ChangelogConfig::default(), doctor: DoctorConfig::default(), @@ -731,6 +798,71 @@ impl ConfigFile { } } + /// Reject an `adapter` table that cannot describe a real manifest, and the + /// lifecycle it cannot reach. + fn validate_adapter(&self) -> Result<()> { + let Some(adapter) = &self.adapter else { + // Without the table there is nothing adapter-specific to check — + // `hex` is the ordinary default for a Gleam workspace. + return Ok(()); + }; + if adapter.manifest.is_empty() { + bail!("`adapter.manifest` is empty; it names the member manifest, e.g. `apm.yml`"); + } + let path = Path::new(&adapter.manifest); + if path.is_absolute() + || adapter + .manifest + .split(['/', '\\']) + .any(|part| part == ".." || part == ".") + { + bail!( + "`adapter.manifest` `{}` must be a relative path inside the member directory", + adapter.manifest + ); + } + // A member manifest named `trellis.toml` would be mistaken for a + // workspace root by `Workspace::find_root` walking up from inside it. + if adapter.manifest_file_name() == crate::workspace::TRELLIS_TOML { + bail!( + "`adapter.manifest` may not be `{}`; that name marks a workspace root", + crate::workspace::TRELLIS_TOML + ); + } + adapter + .format() + .map_err(|err| anyhow::anyhow!("{err:#}").context("invalid `adapter.manifest`"))?; + for (key, value) in [("name", &adapter.name), ("version", &adapter.version)] { + if value.is_empty() || value.split('.').any(str::is_empty) { + bail!( + "`adapter.{key}` `{value}` is not a dotted field path, e.g. `{key}` or \ + `package.{key}`" + ); + } + } + let hex = std::iter::once((None, self.publish.lifecycle.default)) + .chain( + self.publish + .lifecycle + .packages + .iter() + .map(|(glob, lifecycle)| (Some(glob.as_str()), *lifecycle)), + ) + .find(|(_, lifecycle)| *lifecycle == ReleaseLifecycle::Hex); + if let Some((glob, _)) = hex { + let key = match glob { + Some(glob) => format!("publish.lifecycle.packages.\"{glob}\""), + None => "publish.lifecycle.default".to_string(), + }; + bail!( + "`{key}` is `hex`, but an `adapter` workspace has no Hex packages to publish; \ + the highest lifecycle it reaches is `{}`", + ReleaseLifecycle::GitOnly.key() + ); + } + Ok(()) + } + /// Reject an explicitly empty `members` list (omit it to auto-discover), /// task names that could collide with a reserved `exclude` key, and any /// `exclude` key that misuses the reserved prefix without being one. @@ -759,6 +891,7 @@ impl ConfigFile { ); } } + self.validate_adapter()?; self.reject_removed_keys()?; if !self.publish.exact_tag_format.contains("{version}") { bail!( @@ -1025,7 +1158,7 @@ mod tests { categories = ["build", "publish"] uncategorized_label = "Everything else" "###; - let config = ConfigFile::from_gleam_toml(text).unwrap(); + let config = ConfigFile::from_toml(text).unwrap(); assert_eq!(config.members.as_deref().unwrap().len(), 2); assert_eq!(config.exclude["docs"], vec!["examples/*"]); assert_eq!( @@ -1049,7 +1182,7 @@ mod tests { /// upgrade. The changelog axis defaults the other way, on purpose. #[test] fn changelog_strictness_defaults_to_error_and_parses() { - let unset = ConfigFile::from_gleam_toml("[tools.trellis]\n").unwrap(); + let unset = ConfigFile::from_toml("[tools.trellis]\n").unwrap(); assert_eq!(unset.changelog.strictness, Strictness::Error); assert_eq!(ChangelogConfig::default().strictness, Strictness::Error); assert_eq!( @@ -1062,7 +1195,7 @@ mod tests { ("error", Strictness::Error), ("off", Strictness::Off), ] { - let config = ConfigFile::from_gleam_toml(&format!( + let config = ConfigFile::from_toml(&format!( "[tools.trellis.changelog]\nstrictness = \"{value}\"\n" )) .unwrap(); @@ -1079,7 +1212,7 @@ mod tests { /// CLI command is the user's business, not a deprecated key. #[test] fn hyphens_in_category_labels_are_not_deprecations() { - let config = ConfigFile::from_gleam_toml( + let config = ConfigFile::from_toml( "[tools.trellis.changelog]\ncategories = [\"markdown-help\", \"no-color\"]\n", ) .unwrap(); @@ -1093,7 +1226,7 @@ mod tests { #[test] fn kind_headings_demote_only_when_categories_are_in_play() { let parse = |table: &str| { - ConfigFile::from_gleam_toml(&format!("[tools.trellis.changelog]\n{table}")) + ConfigFile::from_toml(&format!("[tools.trellis.changelog]\n{table}")) .unwrap() .changelog }; @@ -1115,7 +1248,7 @@ mod tests { #[test] fn uncategorized_label_may_not_collide_with_a_category() { - let err = ConfigFile::from_gleam_toml( + let err = ConfigFile::from_toml( "[tools.trellis.changelog]\ncategories = [\"build\", \"Other\"]\n", ) .unwrap_err(); @@ -1126,7 +1259,7 @@ mod tests { ); // Renaming either side settles it. - ConfigFile::from_gleam_toml( + ConfigFile::from_toml( "[tools.trellis.changelog]\ncategories = [\"build\", \"Other\"]\nuncategorized_label = \"Misc\"\n", ) .unwrap(); @@ -1155,7 +1288,7 @@ mod tests { kind-format = "### {{ kind }}" change-format = "* {{ body }}" "####; - let config = ConfigFile::from_gleam_toml(text).unwrap(); + let config = ConfigFile::from_toml(text).unwrap(); assert!(config.tasks["lint"].needs_deps); assert_eq!(config.publish.series_tag_format, "{name}@{series}"); assert_eq!( @@ -1189,7 +1322,7 @@ mod tests { #[test] fn a_deprecated_key_names_its_replacement() { - let config = ConfigFile::from_gleam_toml( + let config = ConfigFile::from_toml( "[tools.trellis]\n[tools.trellis.publish]\nseries-tag-format = \"v{series}\"\n", ) .unwrap(); @@ -1209,7 +1342,7 @@ mod tests { /// accepting it would invent a migration for a key with no history. #[test] fn keys_added_after_the_rename_have_no_kebab_spelling() { - let config = ConfigFile::from_gleam_toml( + let config = ConfigFile::from_toml( "[tools.trellis]\n[tools.trellis.publish]\nseries-tags = [\"major\"]\n", ) .unwrap(); @@ -1228,7 +1361,7 @@ mod tests { /// be a warning nobody can act on. #[test] fn hyphens_in_free_form_table_keys_are_not_deprecations() { - let config = ConfigFile::from_gleam_toml( + let config = ConfigFile::from_toml( r###" [tools.trellis] members = ["packages/*"] @@ -1252,7 +1385,7 @@ mod tests { /// leaves the task's own name alone. #[test] fn a_stale_key_under_a_hyphenated_task_name_is_still_reported() { - let config = ConfigFile::from_gleam_toml( + let config = ConfigFile::from_toml( "[tools.trellis.tasks.check-all]\ncommand = \"gleam check\"\nneeds-deps = true\n", ) .unwrap(); @@ -1269,7 +1402,7 @@ mod tests { /// no alias — they are simply unknown. #[test] fn keys_added_after_the_last_kebab_release_have_no_alias() { - let config = ConfigFile::from_gleam_toml( + let config = ConfigFile::from_toml( r###" [tools.trellis.changelog] dependency-kind = "Docs" @@ -1299,8 +1432,7 @@ mod tests { #[test] fn minimal_config_gets_defaults() { - let config = - ConfigFile::from_gleam_toml("[tools.trellis]\nmembers = [\"packages/*\"]").unwrap(); + let config = ConfigFile::from_toml("[tools.trellis]\nmembers = [\"packages/*\"]").unwrap(); assert!(config.exclude.is_empty()); assert_eq!(config.publish.exact_tag_format, "{name}-v{version}"); assert_eq!( @@ -1338,15 +1470,14 @@ mod tests { #[test] fn lifecycle_defaults_to_hex_with_no_package_overrides() { - let config = - ConfigFile::from_gleam_toml("[tools.trellis]\nmembers = [\"packages/*\"]").unwrap(); + let config = ConfigFile::from_toml("[tools.trellis]\nmembers = [\"packages/*\"]").unwrap(); assert_eq!(config.publish.lifecycle.default, ReleaseLifecycle::Hex); assert!(config.publish.lifecycle.packages.is_empty()); } #[test] fn lifecycle_parses_nested_table_and_inline_map() { - let config = ConfigFile::from_gleam_toml( + let config = ConfigFile::from_toml( r###" [tools.trellis.publish.lifecycle] default = "hex" @@ -1370,10 +1501,9 @@ mod tests { #[test] fn lifecycle_default_can_be_overridden() { - let config = ConfigFile::from_gleam_toml( - "[tools.trellis.publish.lifecycle]\ndefault = \"workspace\"\n", - ) - .unwrap(); + let config = + ConfigFile::from_toml("[tools.trellis.publish.lifecycle]\ndefault = \"workspace\"\n") + .unwrap(); assert_eq!( config.publish.lifecycle.default, ReleaseLifecycle::Workspace @@ -1382,16 +1512,15 @@ mod tests { #[test] fn lifecycle_rejects_an_unknown_value() { - let err = ConfigFile::from_gleam_toml( - "[tools.trellis.publish.lifecycle]\ndefault = \"published\"\n", - ) - .unwrap_err(); + let err = + ConfigFile::from_toml("[tools.trellis.publish.lifecycle]\ndefault = \"published\"\n") + .unwrap_err(); assert!(format!("{err:#}").contains("published")); } #[test] fn lifecycle_package_glob_rejects_an_unknown_value() { - let err = ConfigFile::from_gleam_toml( + let err = ConfigFile::from_toml( "[tools.trellis.publish.lifecycle]\npackages = { \"pkg/*\" = \"nope\" }\n", ) .unwrap_err(); @@ -1400,7 +1529,7 @@ mod tests { #[test] fn dependency_kind_must_name_a_configured_kind() { - let err = ConfigFile::from_gleam_toml( + let err = ConfigFile::from_toml( r###" [tools.trellis.changelog] kinds = [{ label = "Docs", bump = "patch" }] @@ -1422,7 +1551,7 @@ mod tests { #[test] fn dependency_kind_may_point_at_an_existing_kind() { - let config = ConfigFile::from_gleam_toml( + let config = ConfigFile::from_toml( r###" [tools.trellis.changelog] dependency_kind = "Docs" @@ -1440,7 +1569,7 @@ mod tests { #[test] fn omitted_members_means_auto_discovery() { - let config = ConfigFile::from_gleam_toml( + let config = ConfigFile::from_toml( "[tools.trellis]\nexclude = { \"@members\" = [\"tests/fixtures/*\"] }", ) .unwrap(); @@ -1453,7 +1582,7 @@ mod tests { #[test] fn empty_members_is_a_clear_error() { - let err = ConfigFile::from_gleam_toml("[tools.trellis]\nmembers = []").unwrap_err(); + let err = ConfigFile::from_toml("[tools.trellis]\nmembers = []").unwrap_err(); assert!(err.to_string().contains("auto-discover"), "{err:#}"); } @@ -1468,7 +1597,7 @@ mod tests { #[test] fn task_name_may_not_use_the_reserved_prefix() { - let err = ConfigFile::from_gleam_toml( + let err = ConfigFile::from_toml( "[tools.trellis]\nmembers = [\"packages/*\"]\n[tools.trellis.tasks.\"@lint\"]\ncommand = \"x\"\n", ) .unwrap_err(); @@ -1477,7 +1606,7 @@ mod tests { #[test] fn unknown_reserved_exclude_key_is_a_clear_error() { - let err = ConfigFile::from_gleam_toml( + let err = ConfigFile::from_toml( "[tools.trellis]\nmembers = [\"packages/*\"]\nexclude = { \"@relase\" = [\"x\"] }\n", ) .unwrap_err(); @@ -1486,7 +1615,7 @@ mod tests { #[test] fn missing_tools_trellis_is_a_clear_error() { - let err = ConfigFile::from_gleam_toml("name = \"pkg\"\nversion = \"1.0.0\"").unwrap_err(); + let err = ConfigFile::from_toml("name = \"pkg\"\nversion = \"1.0.0\"").unwrap_err(); assert!(err.to_string().contains("[tools.trellis]")); } @@ -1521,8 +1650,7 @@ mod tests { #[test] fn formats_series_tags() { let minor = [TagLevel::Minor]; - let config = - ConfigFile::from_gleam_toml("[tools.trellis]\nmembers = [\"packages/*\"]").unwrap(); + let config = ConfigFile::from_toml("[tools.trellis]\nmembers = [\"packages/*\"]").unwrap(); assert_eq!(config.publish.series_tag_format, "{name}-v{series}"); assert_eq!(config.series_tags("core", "0.0.3", &minor), ["core-v0.0"]); assert!(config.series_tags("core", "0.0.3-rc.1", &minor).is_empty()); @@ -1533,7 +1661,7 @@ mod tests { .is_empty() ); - let repo_wide = ConfigFile::from_gleam_toml( + let repo_wide = ConfigFile::from_toml( "[tools.trellis]\n[tools.trellis.publish]\nseries_tag_format = \"v{series}\"\n", ) .unwrap(); @@ -1542,7 +1670,7 @@ mod tests { #[test] fn parses_package_tags_and_overrides() { - let config = ConfigFile::from_gleam_toml( + let config = ConfigFile::from_toml( r###" [tools.trellis] members = ["packages/*"] @@ -1568,8 +1696,7 @@ mod tests { /// unless asked for — and `package_tags` has to preserve it. #[test] fn package_tags_default_to_exact_only() { - let config = - ConfigFile::from_gleam_toml("[tools.trellis]\nmembers = [\"packages/*\"]").unwrap(); + let config = ConfigFile::from_toml("[tools.trellis]\nmembers = [\"packages/*\"]").unwrap(); assert_eq!(config.publish.package_tags, [TagLevel::Exact]); assert!(config.publish.package_tags_overrides.is_empty()); assert!(!TagLevel::Exact.is_series()); @@ -1578,7 +1705,7 @@ mod tests { #[test] fn an_empty_override_list_is_rejected() { - let err = ConfigFile::from_gleam_toml( + let err = ConfigFile::from_toml( "[tools.trellis.publish]\npackage_tags_overrides = { \"packages/x\" = [] }\n", ) .unwrap_err(); @@ -1587,7 +1714,7 @@ mod tests { #[test] fn series_tag_format_must_carry_the_series_placeholder() { - let err = ConfigFile::from_gleam_toml( + let err = ConfigFile::from_toml( "[tools.trellis]\n[tools.trellis.publish]\nseries_tag_format = \"{name}-latest\"\n", ) .unwrap_err(); @@ -1596,7 +1723,7 @@ mod tests { #[test] fn parses_and_formats_the_repository_tag() { - let config = ConfigFile::from_gleam_toml( + let config = ConfigFile::from_toml( "[tools.trellis.publish]\n\ repository_tag_package = \"core\"\nrepository_tag_format = \"v{series}\"\n\ repository_tags = [\"minor\"]\n", @@ -1612,7 +1739,7 @@ mod tests { #[test] fn the_repository_tag_is_optional() { - let config = ConfigFile::from_gleam_toml("[tools.trellis]").unwrap(); + let config = ConfigFile::from_toml("[tools.trellis]").unwrap(); assert!(config.publish.repository_tag_package.is_none()); assert!(config.repository_tags("1.2.3").is_empty()); } @@ -1635,7 +1762,7 @@ mod tests { ["repository_tag_package", "repository_tag_format"], ), ] { - let err = ConfigFile::from_gleam_toml(&format!("[tools.trellis.publish]\n{partial}\n")) + let err = ConfigFile::from_toml(&format!("[tools.trellis.publish]\n{partial}\n")) .unwrap_err(); let message = format!("{err:#}"); for key in missing { @@ -1643,7 +1770,7 @@ mod tests { } } // None of the three is the ordinary case: the feature is off. - let off = ConfigFile::from_gleam_toml("[tools.trellis]").unwrap(); + let off = ConfigFile::from_toml("[tools.trellis]").unwrap(); assert!(off.publish.repository_tags.is_empty()); assert!(off.repository_tags("1.2.3").is_empty()); } @@ -1651,7 +1778,7 @@ mod tests { #[test] fn both_levels_produce_two_tags_at_every_major() { let both = [TagLevel::Major, TagLevel::Minor]; - let config = ConfigFile::from_gleam_toml("[tools.trellis]").unwrap(); + let config = ConfigFile::from_toml("[tools.trellis]").unwrap(); assert_eq!( config.series_tags("core", "0.10.3", &both), ["core-v0", "core-v0.10"] @@ -1672,7 +1799,7 @@ mod tests { /// levels are stated rather than inferred from `package_tags`. #[test] fn the_repository_tag_takes_its_own_levels() { - let config = ConfigFile::from_gleam_toml( + let config = ConfigFile::from_toml( "[tools.trellis.publish]\npackage_tags = [\"exact\", \"major\", \"minor\"]\n\ repository_tag_package = \"core\"\nrepository_tag_format = \"v{series}\"\n\ repository_tags = [\"major\"]\n", @@ -1684,13 +1811,13 @@ mod tests { #[test] fn empty_package_tags_are_rejected_and_the_error_lists_the_levels() { - let err = ConfigFile::from_gleam_toml("[tools.trellis.publish]\npackage_tags = []\n") - .unwrap_err(); + let err = + ConfigFile::from_toml("[tools.trellis.publish]\npackage_tags = []\n").unwrap_err(); let message = format!("{err:#}"); assert!(message.contains("package_tags"), "{message}"); assert!(message.contains("`major`, `minor`, `exact`"), "{message}"); - let err = ConfigFile::from_gleam_toml( + let err = ConfigFile::from_toml( "[tools.trellis.publish]\n\ repository_tag_package = \"core\"\nrepository_tag_format = \"v{series}\"\n\ repository_tags = []\n", @@ -1703,7 +1830,7 @@ mod tests { /// version, which is what package exact tags are for. #[test] fn the_repository_tag_rejects_the_exact_level() { - let err = ConfigFile::from_gleam_toml( + let err = ConfigFile::from_toml( "[tools.trellis.publish]\n\ repository_tag_package = \"core\"\nrepository_tag_format = \"v{series}\"\n\ repository_tags = [\"exact\"]\n", @@ -1728,14 +1855,14 @@ mod tests { ), ]; for (removed, replacement) in cases { - let err = ConfigFile::from_gleam_toml(&format!("[tools.trellis.publish]\n{removed}\n")) + let err = ConfigFile::from_toml(&format!("[tools.trellis.publish]\n{removed}\n")) .unwrap_err(); let message = format!("{err:#}"); assert!(message.contains("has been removed"), "{message}"); assert!(message.contains(replacement), "{message}"); } // The old sub-table is matched by prefix, through its nested keys. - let err = ConfigFile::from_gleam_toml( + let err = ConfigFile::from_toml( "[tools.trellis.publish.repository_series]\npackage = \"core\"\nformat = \"v{series}\"\n", ) .unwrap_err(); @@ -1743,16 +1870,15 @@ mod tests { assert!(message.contains("publish.repository_series"), "{message}"); assert!(message.contains("repository_tag_package"), "{message}"); // A pre-0.8 kebab spelling of a removed key reports the same way. - let err = ConfigFile::from_gleam_toml("[tools.trellis.publish]\ntag-mode = \"series\"\n") - .unwrap_err(); + let err = + ConfigFile::from_toml("[tools.trellis.publish]\ntag-mode = \"series\"\n").unwrap_err(); assert!(format!("{err:#}").contains("package_tags"), "{err:#}"); } #[test] fn an_unknown_series_level_names_the_vocabulary() { - let err = - ConfigFile::from_gleam_toml("[tools.trellis.publish]\npackage_tags = [\"patch\"]\n") - .unwrap_err(); + let err = ConfigFile::from_toml("[tools.trellis.publish]\npackage_tags = [\"patch\"]\n") + .unwrap_err(); let message = format!("{err:#}"); assert!(message.contains("major"), "{message}"); assert!(message.contains("minor"), "{message}"); @@ -1760,7 +1886,7 @@ mod tests { #[test] fn repository_tag_format_must_carry_the_series_placeholder() { - let err = ConfigFile::from_gleam_toml( + let err = ConfigFile::from_toml( "[tools.trellis.publish]\n\ repository_tag_package = \"core\"\nrepository_tag_format = \"latest\"\n\ repository_tags = [\"minor\"]\n", @@ -1773,7 +1899,7 @@ mod tests { #[test] fn repository_tag_format_rejects_the_name_placeholder() { - let err = ConfigFile::from_gleam_toml( + let err = ConfigFile::from_toml( "[tools.trellis.publish]\n\ repository_tag_package = \"core\"\nrepository_tag_format = \"{name}-v{series}\"\n\ repository_tags = [\"minor\"]\n", @@ -1786,7 +1912,7 @@ mod tests { #[test] fn exact_tag_format_must_carry_the_version_placeholder() { - let err = ConfigFile::from_gleam_toml( + let err = ConfigFile::from_toml( "[tools.trellis.publish]\nexact_tag_format = \"{name}-latest\"\n", ) .unwrap_err(); @@ -1798,4 +1924,96 @@ mod tests { assert!(Bump::Major > Bump::Minor); assert!(Bump::Minor > Bump::Patch); } + + #[test] + fn adapter_field_paths_default_to_name_and_version() { + let config = + ConfigFile::from_toml("[tools.trellis.adapter]\nmanifest = \"apm.yml\"\n").unwrap(); + let adapter = config.adapter.expect("the table was declared"); + assert_eq!(adapter.name, "name"); + assert_eq!(adapter.version, "version"); + assert_eq!(adapter.format().unwrap(), crate::manifest::Format::Yaml); + assert_eq!(adapter.manifest_file_name(), "apm.yml"); + } + + #[test] + fn adapter_manifest_file_name_drops_the_directories() { + let config = ConfigFile::from_toml( + "[tools.trellis.adapter]\nmanifest = \".claude-plugin/plugin.json\"\n", + ) + .unwrap(); + let adapter = config.adapter.expect("the table was declared"); + assert_eq!(adapter.manifest_file_name(), "plugin.json"); + assert_eq!(adapter.format().unwrap(), crate::manifest::Format::Json); + } + + /// The lifecycle ladder tops out one rung lower under an adapter, but only + /// the unwritten default moves. + #[test] + fn adapter_defaults_the_lifecycle_to_git_only() { + let config = + ConfigFile::from_toml("[tools.trellis.adapter]\nmanifest = \"apm.yml\"\n").unwrap(); + assert_eq!(config.publish.lifecycle.default, ReleaseLifecycle::GitOnly); + + let config = ConfigFile::from_toml( + "[tools.trellis.adapter]\nmanifest = \"apm.yml\"\n\ + [tools.trellis.publish.lifecycle]\ndefault = \"workspace\"\n", + ) + .unwrap(); + assert_eq!( + config.publish.lifecycle.default, + ReleaseLifecycle::Workspace + ); + + // Without the table, `hex` remains the default. + let config = ConfigFile::from_toml("[tools.trellis]\n").unwrap(); + assert_eq!(config.publish.lifecycle.default, ReleaseLifecycle::Hex); + } + + #[test] + fn adapter_rejects_a_hex_lifecycle_wherever_it_is_written() { + for table in [ + "[tools.trellis.publish.lifecycle]\ndefault = \"hex\"\n", + "[tools.trellis.publish.lifecycle.packages]\n\"packages/*\" = \"hex\"\n", + ] { + let err = ConfigFile::from_toml(&format!( + "[tools.trellis.adapter]\nmanifest = \"apm.yml\"\n{table}" + )) + .unwrap_err(); + let message = format!("{err:#}"); + assert!(message.contains("no Hex packages to publish"), "{message}"); + } + } + + #[test] + fn adapter_rejects_a_manifest_it_cannot_read() { + for (manifest, expected) in [ + ("", "is empty"), + ("../shared/apm.yml", "relative path inside the member"), + ("/etc/apm.yml", "relative path inside the member"), + ("Makefile", "invalid `adapter.manifest`"), + ("trellis.toml", "marks a workspace root"), + ] { + let err = ConfigFile::from_toml(&format!( + "[tools.trellis.adapter]\nmanifest = \"{manifest}\"\n" + )) + .unwrap_err(); + let message = format!("{err:#}"); + assert!(message.contains(expected), "{manifest}: {message}"); + } + } + + #[test] + fn adapter_rejects_a_malformed_field_path() { + for path in ["", "package."] { + let err = ConfigFile::from_toml(&format!( + "[tools.trellis.adapter]\nmanifest = \"apm.yml\"\nversion = \"{path}\"\n" + )) + .unwrap_err(); + assert!( + format!("{err:#}").contains("is not a dotted field path"), + "{path}: {err:#}" + ); + } + } } diff --git a/src/git.rs b/src/git.rs index d36918d..0bcd740 100644 --- a/src/git.rs +++ b/src/git.rs @@ -190,13 +190,18 @@ pub fn repo_root(dir: &Path) -> Option { .map(|out| PathBuf::from(out.trim())) } -/// Every non-gitignored `gleam.toml` under `cwd` — tracked and untracked +/// Every non-gitignored member manifest under `cwd` — tracked and untracked /// alike, so freshly created packages are discovered before their first /// commit. Paths are relative to `cwd`. -pub fn ls_gleam_manifests(cwd: &Path) -> Result> { +/// +/// `rel_path` is the manifest's path within a member directory: `gleam.toml` +/// normally, or whatever `[tools.trellis.adapter].manifest` names, which may +/// carry directories of its own (`.claude-plugin/plugin.json`). +pub fn ls_manifests(cwd: &Path, rel_path: &str) -> Result> { // A plain pathspec wildcard matches across `/`, so `*gleam.toml` finds - // manifests at any depth; the basename filter drops accidental matches + // manifests at any depth; the suffix filter drops accidental matches // like `mygleam.toml`. + let file_name = rel_path.rsplit('/').next().unwrap_or(rel_path); let text = git_stdout( cwd, &[ @@ -205,18 +210,27 @@ pub fn ls_gleam_manifests(cwd: &Path) -> Result> { "--others", "--exclude-standard", "--", - "*gleam.toml", + &format!("*{file_name}"), ], )?; Ok(lines(&text) - .filter(|path| { - Path::new(path) - .file_name() - .is_some_and(|name| name == "gleam.toml") - }) + .filter(|path| manifest_dir(path, rel_path).is_some()) .collect()) } +/// The member directory owning `path`, when `path` is exactly `/`. +/// `""` means the manifest sits at the search root. `None` means `path` merely +/// ends in a similar name (`mygleam.toml`, `other/plugin.json` under a +/// different layout). +pub fn manifest_dir<'a>(path: &'a str, rel_path: &str) -> Option<&'a str> { + let dir = path.strip_suffix(rel_path)?; + match dir.strip_suffix('/') { + Some(dir) => Some(dir), + None if dir.is_empty() => Some(""), + None => None, + } +} + /// `-c user.name=... -c user.email=...` args to prepend to a git command that /// creates a commit or annotated tag, but only when no identity is /// configured (CI runners) — never overriding the user's own config. diff --git a/src/gleam.rs b/src/gleam.rs index 0c2e3f0..543db17 100644 --- a/src/gleam.rs +++ b/src/gleam.rs @@ -72,6 +72,34 @@ impl GleamManifest { Self::parse(&text).with_context(|| format!("failed to parse {}", path.display())) } + /// Read a member's identity out of a non-Gleam manifest, as + /// `[tools.trellis.adapter]` describes it. + /// + /// Only name and version are read. Dependencies are deliberately empty: + /// an adapter declares no dependency edges in this release, so the graph + /// is flat and every consumer of it degrades to member order. + pub fn load_adapted(path: &Path, adapter: &crate::config::AdapterConfig) -> Result { + let format = adapter.format()?; + let text = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + let read = |field: &str, key: &str| -> Result { + crate::manifest::read_string(&text, format, field) + .with_context(|| format!("failed to parse {}", path.display()))? + .with_context(|| { + format!( + "{} has no `{field}` (the `adapter.{key}` field path)", + path.display() + ) + }) + }; + Ok(Self { + name: read(&adapter.name, "name")?, + version: read(&adapter.version, "version")?, + dependencies: Vec::new(), + has_trellis_config: false, + }) + } + pub fn parse(text: &str) -> Result { let raw: RawManifest = toml::from_str(text)?; let mut dependencies = Vec::new(); diff --git a/src/main.rs b/src/main.rs index 87107b1..b63e3b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod gleam; mod hex; mod json; mod lockfile; +mod manifest; mod rewrite; mod runner; mod term; diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..095220f --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,625 @@ +//! Surgical reads and writes of a single string scalar in a manifest, at a +//! dotted key path, for the three formats a workspace member's manifest can be +//! written in. +//! +//! "Surgical" is the same promise [`crate::lockfile`] makes for `manifest.toml`: +//! every byte the edit does not target survives verbatim — comments, key order, +//! indentation, quote style, trailing newline. A hand-maintained +//! `.claude-plugin/plugin.json` or `apm.yml` is a file its owner reads, so a +//! version bump may not reflow it. +//! +//! TOML goes through `toml_edit` and the decor-clone idiom used everywhere else +//! in the codebase. JSON and YAML share one implementation: YAML 1.2 is a strict +//! superset of JSON, so a single `saphyr-parser` event walk locates the target +//! scalar in both, and the edit is a byte splice. +//! +//! Two facts about `saphyr-parser` that this module works around, both verified +//! against 0.0.12 rather than taken from its docs: +//! +//! * [`Marker::index`] is a **char** index, not a byte offset, despite what its +//! doc comment says — hence [`byte_offset`]. +//! * A quoted scalar's `span.end` in YAML block context runs to end-of-line, +//! swallowing trailing whitespace and any comment. Only `span.start` and the +//! [`ScalarStyle`] are trustworthy, so [`scalar_extent`] re-lexes the scalar +//! from its start rather than believing the end marker. + +use anyhow::{Context, Result, bail}; +use saphyr_parser::{Event, Parser, ScalarStyle, Span, SpannedEventReceiver}; +use std::path::Path; + +/// The manifest formats a member's manifest may be written in, derived from the +/// file extension — there is no format key to configure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + Toml, + Json, + Yaml, +} + +impl Format { + /// Every extension trellis recognises, for error messages. + pub const EXTENSIONS: [&'static str; 4] = ["toml", "json", "yaml", "yml"]; + + pub fn from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map(str::to_ascii_lowercase); + match extension.as_deref() { + Some("toml") => Ok(Self::Toml), + Some("json") => Ok(Self::Json), + Some("yaml" | "yml") => Ok(Self::Yaml), + _ => bail!( + "cannot tell the manifest format of `{}` from its extension; expected one of {}", + path.display(), + Self::EXTENSIONS.join(", ") + ), + } + } +} + +/// The string scalar at `path`, or `None` when no such key exists. +/// +/// A path resolving to a mapping, a sequence, or a non-string scalar is an +/// error rather than a `None` — silently reporting "absent" for a key that is +/// present but the wrong shape would turn a typo'd adapter path into a missing +/// version. +pub fn read_string(text: &str, format: Format, path: &str) -> Result> { + match format { + Format::Toml => toml_read(text, path), + Format::Json | Format::Yaml => Ok(locate(text, path)?.map(|hit| hit.value)), + } +} + +/// Replace the string scalar at `path` with `value`, leaving every other byte +/// of `text` untouched. +/// +/// Errors when `path` names nothing: this never inserts a key. A manifest +/// missing the field the adapter points at is a configuration problem to +/// report, not a hole to fill in silently. +pub fn write_string(text: &str, format: Format, path: &str, value: &str) -> Result { + match format { + Format::Toml => toml_write(text, path, value), + Format::Json | Format::Yaml => { + let Some(hit) = locate(text, path)? else { + bail!("no `{path}` field"); + }; + let range = scalar_extent(text, &hit)?; + let replacement = match format { + // A JSON scalar is always a double-quoted string, whatever the + // YAML scanner called its style. + Format::Json => serde_json::to_string(value).expect("a str always serialises"), + _ => yaml_scalar(value, hit.style), + }; + let mut out = String::with_capacity(text.len() + replacement.len()); + out.push_str(&text[..range.0]); + out.push_str(&replacement); + out.push_str(&text[range.1..]); + Ok(out) + } + } +} + +// --- TOML ------------------------------------------------------------------- + +fn toml_read(text: &str, path: &str) -> Result> { + let doc: toml_edit::DocumentMut = text.parse().context("failed to parse TOML")?; + let mut item = doc.as_item(); + for key in path.split('.') { + match item.get(key) { + // `Item::None` is toml_edit's placeholder for a key that was never + // there, which indexing hands back rather than a `None`. + Some(next) if !next.is_none() => item = next, + _ => return Ok(None), + } + } + match item.as_str() { + Some(value) => Ok(Some(value.to_string())), + None => bail!("`{path}` is not a string"), + } +} + +fn toml_write(text: &str, path: &str, value: &str) -> Result { + let mut doc: toml_edit::DocumentMut = text.parse().context("failed to parse TOML")?; + let mut item = doc.as_item_mut(); + for key in path.split('.') { + match item.get_mut(key) { + Some(next) if !next.is_none() => item = next, + _ => bail!("no `{path}` field"), + } + } + let Some(existing) = item.as_value_mut() else { + bail!("`{path}` is not a string"); + }; + if existing.as_str().is_none() { + bail!("`{path}` is not a string"); + } + let mut replacement = toml_edit::Value::from(value); + // Carry the original's surrounding whitespace and comments across, the way + // `lockfile::patch_locked_versions` and `rewrite::rewrite_path_deps` do. + *replacement.decor_mut() = existing.decor().clone(); + *existing = replacement; + Ok(doc.to_string()) +} + +// --- JSON and YAML ---------------------------------------------------------- + +/// Path segment standing for "an element of a sequence". Dotted paths address +/// mapping keys only, and no real key can contain a NUL, so pushing this makes +/// everything under a sequence unmatchable rather than accidentally matched. +const SEQUENCE_SEGMENT: &str = "\0"; + +struct Hit { + value: String, + style: ScalarStyle, + /// Char index of the scalar's first character in the source. + start: usize, +} + +/// What a container in the event stream is, and — for a mapping — whether the +/// next scalar is a key or a value. +enum Container { + Mapping { expecting_key: bool }, + Sequence, +} + +struct Locator<'a> { + target: Vec<&'a str>, + path: Vec, + stack: Vec, + /// Whether each open container pushed a segment onto `path` (the document + /// root does not). + owns_segment: Vec, + pending_key: Option, + hit: Option, + /// A value at the target path that is not a string scalar. + wrong_shape: bool, +} + +impl<'a> Locator<'a> { + fn new(target: &'a str) -> Self { + Self { + target: target.split('.').collect(), + path: Vec::new(), + stack: Vec::new(), + owns_segment: Vec::new(), + pending_key: None, + hit: None, + wrong_shape: false, + } + } + + /// The path segment naming the value about to be visited, consuming the + /// pending mapping key when there is one. + fn value_segment(&mut self) -> String { + match self.stack.last() { + Some(Container::Mapping { .. }) => self.pending_key.take().unwrap_or_default(), + _ => SEQUENCE_SEGMENT.to_string(), + } + } + + fn at_target(&self) -> bool { + self.path.len() == self.target.len() + && self.path.iter().zip(&self.target).all(|(a, b)| a == b) + } + + /// A mapping's next scalar is a key again, once its current value is done. + fn value_done(&mut self) { + if let Some(Container::Mapping { expecting_key }) = self.stack.last_mut() { + *expecting_key = true; + } + } +} + +impl<'i> SpannedEventReceiver<'i> for Locator<'_> { + fn on_event(&mut self, event: Event<'i>, span: Span) { + if self.hit.is_some() || self.wrong_shape { + return; + } + match event { + Event::Scalar(value, style, _, _) => { + if let Some(Container::Mapping { expecting_key }) = self.stack.last_mut() + && *expecting_key + { + *expecting_key = false; + self.pending_key = Some(value.into_owned()); + return; + } + let segment = self.value_segment(); + self.path.push(segment); + if self.at_target() { + self.hit = Some(Hit { + value: value.into_owned(), + style, + start: span.start.index(), + }); + } + self.path.pop(); + self.value_done(); + } + Event::MappingStart(..) | Event::SequenceStart(..) => { + let owns = !self.stack.is_empty(); + if owns { + let segment = self.value_segment(); + self.path.push(segment); + // A container sitting at the target path is a value of the + // wrong shape, not an absent key. + if self.at_target() { + self.wrong_shape = true; + } + } + self.owns_segment.push(owns); + self.stack.push(match event { + Event::MappingStart(..) => Container::Mapping { + expecting_key: true, + }, + _ => Container::Sequence, + }); + } + Event::MappingEnd | Event::SequenceEnd => { + self.stack.pop(); + if self.owns_segment.pop().unwrap_or(false) { + self.path.pop(); + } + self.value_done(); + } + // An alias resolves to a value trellis cannot splice in place; the + // path simply does not match through one. + Event::Alias(_) => { + let segment = self.value_segment(); + self.path.push(segment); + if self.at_target() { + self.wrong_shape = true; + } + self.path.pop(); + self.value_done(); + } + _ => {} + } + } +} + +fn locate(text: &str, path: &str) -> Result> { + if path.is_empty() { + bail!("empty field path"); + } + let mut locator = Locator::new(path); + Parser::new_from_str(text) + .load(&mut locator, false) + .map_err(|err| anyhow::anyhow!("{err}"))?; + if locator.wrong_shape { + bail!("`{path}` is not a string"); + } + Ok(locator.hit) +} + +/// The byte range the scalar at `hit` occupies in `text`, quotes included. +/// +/// Re-lexed from the start marker rather than read off `span.end`, which a +/// quoted scalar in YAML block context reports as end-of-line. +fn scalar_extent(text: &str, hit: &Hit) -> Result<(usize, usize)> { + let start = byte_offset(text, hit.start) + .with_context(|| format!("scalar at char {} is out of range", hit.start))?; + let rest = &text[start..]; + let end = match hit.style { + ScalarStyle::Plain => { + if !rest.starts_with(&hit.value) { + bail!("cannot locate the end of the value (multi-line plain scalar?)"); + } + start + hit.value.len() + } + ScalarStyle::SingleQuoted => start + quoted_len(rest, b'\'', false)?, + ScalarStyle::DoubleQuoted => start + quoted_len(rest, b'"', true)?, + ScalarStyle::Literal | ScalarStyle::Folded => { + bail!("cannot rewrite a block scalar in place"); + } + }; + Ok((start, end)) +} + +/// Length in bytes of the quoted scalar at the head of `text`, closing quote +/// included. `backslash_escapes` distinguishes double-quoted style (`\"`) from +/// single-quoted style, where a literal quote is written `''`. +fn quoted_len(text: &str, quote: u8, backslash_escapes: bool) -> Result { + let bytes = text.as_bytes(); + if bytes.first() != Some("e) { + bail!("expected a quoted value"); + } + let mut i = 1; + while i < bytes.len() { + match bytes[i] { + b'\\' if backslash_escapes => i += 2, + b if b == quote => { + if !backslash_escapes && bytes.get(i + 1) == Some("e) { + i += 2; + } else { + return Ok(i + 1); + } + } + _ => i += 1, + } + } + bail!("unterminated quoted value") +} + +/// Byte offset of the `char_index`-th character, or the end of the string when +/// the index is exactly its char length. +fn byte_offset(text: &str, char_index: usize) -> Option { + text.char_indices() + .map(|(offset, _)| offset) + .chain(std::iter::once(text.len())) + .nth(char_index) +} + +/// `value` written in YAML, keeping `style` when it can still express the value. +fn yaml_scalar(value: &str, style: ScalarStyle) -> String { + match style { + ScalarStyle::SingleQuoted => format!("'{}'", value.replace('\'', "''")), + ScalarStyle::Plain if plain_is_safe(value) => value.to_string(), + // JSON's escaping is a subset of YAML's double-quoted style, so a JSON + // string literal is always a valid YAML one. + _ => serde_json::to_string(value).expect("a str always serialises"), + } +} + +/// Whether `value` survives being written as a YAML plain scalar — no quoting +/// needed, and no risk of the reader resolving it to a bool, null, or number. +fn plain_is_safe(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !first.is_ascii_alphanumeric() { + return false; + } + if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-')) { + return false; + } + // The YAML 1.1 boolean and null spellings, which readers in the wild still + // resolve, plus anything a reader would hand back as a number. + const RESERVED: [&str; 10] = [ + "true", "false", "yes", "no", "on", "off", "y", "n", "null", "nan", + ]; + let lowered = value.to_ascii_lowercase(); + !RESERVED.contains(&lowered.as_str()) && value.parse::().is_err() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_comes_from_the_extension() { + assert_eq!(Format::from_path("gleam.toml").unwrap(), Format::Toml); + assert_eq!( + Format::from_path(".claude-plugin/plugin.json").unwrap(), + Format::Json + ); + assert_eq!(Format::from_path("apm.yml").unwrap(), Format::Yaml); + assert_eq!(Format::from_path("a/b.YAML").unwrap(), Format::Yaml); + let err = Format::from_path("Cargo.lock").unwrap_err().to_string(); + assert!(err.contains("toml, json, yaml, yml"), "{err}"); + assert!(Format::from_path("plugin").is_err()); + } + + // --- YAML --------------------------------------------------------------- + + const APM: &str = "\ +# An APM package. +name: my-pkg +version: \"1.2.0\" # keep this comment +dependencies: + apm: + - microsoft/apm-sample-package#v1.0.0 +nested: + deep: + key: plain-value +"; + + #[test] + fn reads_yaml_scalars_at_dotted_paths() { + assert_eq!( + read_string(APM, Format::Yaml, "name").unwrap().as_deref(), + Some("my-pkg") + ); + assert_eq!( + read_string(APM, Format::Yaml, "version") + .unwrap() + .as_deref(), + Some("1.2.0") + ); + assert_eq!( + read_string(APM, Format::Yaml, "nested.deep.key") + .unwrap() + .as_deref(), + Some("plain-value") + ); + assert_eq!(read_string(APM, Format::Yaml, "missing").unwrap(), None); + assert_eq!( + read_string(APM, Format::Yaml, "nested.deep.nope").unwrap(), + None + ); + } + + /// The load-bearing case: a quoted scalar whose `span.end` the parser + /// reports as end-of-line, comment and all. + #[test] + fn yaml_edit_keeps_the_trailing_comment_and_everything_else() { + let out = write_string(APM, Format::Yaml, "version", "1.3.0").unwrap(); + assert_eq!(out, APM.replace("\"1.2.0\"", "\"1.3.0\"")); + assert!(out.contains("version: \"1.3.0\" # keep this comment")); + } + + #[test] + fn yaml_edit_of_a_plain_scalar_stays_plain() { + let out = write_string(APM, Format::Yaml, "nested.deep.key", "other").unwrap(); + assert!(out.contains("key: other\n"), "{out}"); + assert_eq!(out, APM.replace("plain-value", "other")); + } + + #[test] + fn yaml_quotes_a_plain_scalar_when_the_value_needs_it() { + let out = write_string(APM, Format::Yaml, "nested.deep.key", "yes").unwrap(); + assert!(out.contains("key: \"yes\"\n"), "{out}"); + let out = write_string(APM, Format::Yaml, "nested.deep.key", "a: b").unwrap(); + assert!(out.contains("key: \"a: b\"\n"), "{out}"); + } + + #[test] + fn yaml_single_quoted_style_survives() { + let text = "version: '1.2.0' # c\n"; + let out = write_string(text, Format::Yaml, "version", "1.3.0").unwrap(); + assert_eq!(out, "version: '1.3.0' # c\n"); + let out = write_string(text, Format::Yaml, "version", "it's").unwrap(); + assert_eq!(out, "version: 'it''s' # c\n"); + } + + #[test] + fn yaml_multibyte_content_before_the_target_does_not_shift_the_splice() { + let text = "description: naïve — em dash, ümlaut\nversion: \"1.2.0\"\n"; + let out = write_string(text, Format::Yaml, "version", "1.3.0").unwrap(); + assert_eq!(out, text.replace("1.2.0", "1.3.0")); + } + + // --- JSON --------------------------------------------------------------- + + const PLUGIN: &str = "{\n \"name\": \"code-reviewer\",\n \"version\": \"1.2.0\",\n \ + \"author\": { \"name\": \"someone\" },\n \"keywords\": [\"a\", \"b\"]\n}\n"; + + #[test] + fn reads_and_writes_json() { + assert_eq!( + read_string(PLUGIN, Format::Json, "name") + .unwrap() + .as_deref(), + Some("code-reviewer") + ); + assert_eq!( + read_string(PLUGIN, Format::Json, "author.name") + .unwrap() + .as_deref(), + Some("someone") + ); + let out = write_string(PLUGIN, Format::Json, "version", "1.3.0").unwrap(); + assert_eq!(out, PLUGIN.replace("1.2.0", "1.3.0")); + } + + #[test] + fn json_indentation_style_is_irrelevant() { + for text in [ + "{\n\t\"version\": \"1.2.0\"\n}\n", + "{\"version\":\"1.2.0\"}", + "{\n \"a\": 1,\n \"version\": \"1.2.0\"\n}", + ] { + let out = write_string(text, Format::Json, "version", "1.3.0").unwrap(); + assert_eq!(out, text.replace("1.2.0", "1.3.0")); + } + } + + #[test] + fn json_values_are_escaped_on_write() { + let out = write_string(PLUGIN, Format::Json, "name", "a\"b\\c").unwrap(); + assert!(out.contains(r#""name": "a\"b\\c","#), "{out}"); + assert_eq!( + read_string(&out, Format::Json, "name").unwrap().as_deref(), + Some("a\"b\\c") + ); + } + + #[test] + fn json_escapes_before_the_target_do_not_confuse_the_scan() { + let text = r#"{"a": "quote \" and backslash \\", "version": "1.2.0"}"#; + let out = write_string(text, Format::Json, "version", "1.3.0").unwrap(); + assert_eq!(out, text.replace("1.2.0", "1.3.0")); + } + + #[test] + fn sequence_elements_never_satisfy_a_dotted_path() { + // `keywords.a` would match if sequence entries were transparent. + assert_eq!( + read_string(PLUGIN, Format::Json, "keywords.a").unwrap(), + None + ); + assert_eq!( + read_string(APM, Format::Yaml, "dependencies.apm.microsoft").unwrap(), + None + ); + } + + // --- shared behaviour --------------------------------------------------- + + #[test] + fn a_path_naming_a_container_is_an_error_not_an_absence() { + for (text, format, path) in [ + (PLUGIN, Format::Json, "author"), + (PLUGIN, Format::Json, "keywords"), + (APM, Format::Yaml, "nested"), + (APM, Format::Yaml, "nested.deep"), + (APM, Format::Yaml, "dependencies.apm"), + ] { + let err = read_string(text, format, path).unwrap_err().to_string(); + assert!(err.contains("is not a string"), "{path}: {err}"); + } + } + + #[test] + fn writing_a_missing_path_never_inserts() { + for (text, format) in [ + (PLUGIN, Format::Json), + (APM, Format::Yaml), + ("version = \"1.2.0\"\n", Format::Toml), + ] { + let err = write_string(text, format, "nope.nowhere", "1.3.0") + .unwrap_err() + .to_string(); + assert!(err.contains("no `nope.nowhere` field"), "{format:?}: {err}"); + } + } + + #[test] + fn unparseable_input_is_an_error() { + assert!(read_string("{ unterminated", Format::Json, "a").is_err()); + assert!(read_string("a:\n- b\n c: d\n", Format::Yaml, "a").is_err()); + assert!(read_string("nope = ", Format::Toml, "a").is_err()); + } + + // --- TOML --------------------------------------------------------------- + + const GLEAM: &str = "\ +name = \"my_pkg\" +version = \"1.2.0\" # inline comment +gleam = \">= 1.0.0\" + +[dependencies] +gleam_stdlib = \">= 0.34.0\" +"; + + #[test] + fn toml_edits_are_surgical() { + assert_eq!( + read_string(GLEAM, Format::Toml, "version") + .unwrap() + .as_deref(), + Some("1.2.0") + ); + assert_eq!( + read_string(GLEAM, Format::Toml, "dependencies.gleam_stdlib") + .unwrap() + .as_deref(), + Some(">= 0.34.0") + ); + let out = write_string(GLEAM, Format::Toml, "version", "1.3.0").unwrap(); + assert_eq!(out, GLEAM.replace("1.2.0", "1.3.0")); + assert!(out.contains("version = \"1.3.0\" # inline comment")); + } + + #[test] + fn toml_reports_a_missing_or_mistyped_field() { + assert_eq!(read_string(GLEAM, Format::Toml, "missing").unwrap(), None); + let err = read_string(GLEAM, Format::Toml, "dependencies") + .unwrap_err() + .to_string(); + assert!(err.contains("is not a string"), "{err}"); + } +} diff --git a/src/workspace.rs b/src/workspace.rs index 8a0b872..8bfd783 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -11,6 +11,17 @@ use std::path::{Component, Path, PathBuf}; pub const GLEAM_TOML: &str = "gleam.toml"; +/// The alternative home for the `[tools.trellis]` table, for a workspace whose +/// root is not itself a Gleam package — an adapter workspace has no +/// `gleam.toml` for the configuration to live in. +pub const TRELLIS_TOML: &str = "trellis.toml"; + +/// The files that may carry the `[tools.trellis]` table, in precedence order. +/// A dedicated `trellis.toml` wins over a `gleam.toml` beside it, though +/// carrying the table in both at once is an error rather than a precedence +/// question. +pub const CONFIG_HOMES: [&str; 2] = [TRELLIS_TOML, GLEAM_TOML]; + #[derive(Debug)] pub struct Member { pub name: String, @@ -46,6 +57,40 @@ impl Member { } } +impl Workspace { + /// The member manifest's path within a member directory: `gleam.toml`, + /// or whatever `[tools.trellis.adapter].manifest` names. + pub fn manifest_rel(&self) -> &str { + self.config + .adapter + .as_ref() + .map_or(GLEAM_TOML, |adapter| adapter.manifest.as_str()) + } + + /// The adapter table, when this workspace has one. Its presence is what + /// every "is this a Gleam workspace?" question actually asks. + pub fn adapter(&self) -> Option<&crate::config::AdapterConfig> { + self.config.adapter.as_ref() + } + + /// Refuse a command that only means something for Gleam packages. + /// + /// An adapter redefines what a member *is*; it cannot conjure a Gleam + /// toolchain, a `manifest.toml`, or a Hex registry for one. Those commands + /// fail loudly here rather than shelling out to `gleam` in a directory + /// that has never seen it. + pub fn refuse_under_adapter(&self, what: &str) -> Result<()> { + if let Some(adapter) = self.adapter() { + bail!( + "`{what}` is a Gleam command, and this workspace's members are `{}` manifests \ + declared by [tools.trellis.adapter]", + adapter.manifest + ); + } + Ok(()) + } +} + #[derive(Debug)] pub struct Workspace { pub root: PathBuf, @@ -53,6 +98,9 @@ pub struct Workspace { /// True when no `[tools.trellis]` table exists anywhere: the root was /// inferred from git and the configuration is entirely defaulted. pub configless: bool, + /// The file the configuration was read from, relative to `root` — one of + /// [`CONFIG_HOMES`]. Findings about configuration are attributed to it. + pub config_rel: &'static str, /// Members in topological order (dependencies before dependents). pub members: Vec, /// Index of the repository tag anchor member. `None` when the feature @@ -99,9 +147,9 @@ impl Diagnostics { } impl Workspace { - /// Walk up from `start` looking for a `gleam.toml` with a + /// Walk up from `start` looking for a `trellis.toml` or `gleam.toml` with a /// `[tools.trellis]` table — the workspace root marker. Member manifests - /// (gleam.toml without the table) are skipped, so commands work from + /// (a gleam.toml without the table) are skipped, so commands work from /// inside a package, like `git` or `cargo`. /// /// When no manifest anywhere up the tree has the table, trellis runs @@ -115,21 +163,24 @@ impl Workspace { .with_context(|| format!("cannot resolve {}", start.display()))?; let mut unparseable: Vec = Vec::new(); for dir in start.ancestors() { - let manifest = dir.join(GLEAM_TOML); - let Ok(text) = std::fs::read_to_string(&manifest) else { - continue; - }; - match toml::from_str::(&text) { - Ok(document) if crate::config::has_trellis_table(&document) => { - return Ok(dir.to_path_buf()); + for name in CONFIG_HOMES { + let manifest = dir.join(name); + let Ok(text) = std::fs::read_to_string(&manifest) else { + continue; + }; + match toml::from_str::(&text) { + Ok(document) if crate::config::has_trellis_table(&document) => { + return Ok(dir.to_path_buf()); + } + Ok(_) => {} // a package manifest; keep walking + Err(_) => unparseable.push(manifest), } - Ok(_) => {} // a package manifest; keep walking - Err(_) => unparseable.push(manifest), } } + let homes = CONFIG_HOMES.join(" or "); if !unparseable.is_empty() { bail!( - "no {GLEAM_TOML} with a [tools.trellis] table found in {} or any parent \ + "no {homes} with a [tools.trellis] table found in {} or any parent \ directory, and {} could not be parsed and may be the intended workspace root", start.display(), unparseable @@ -143,7 +194,7 @@ impl Workspace { return Ok(root); } bail!( - "no {GLEAM_TOML} with a [tools.trellis] table found in {} or any parent directory, \ + "no {homes} with a [tools.trellis] table found in {} or any parent directory, \ and it is not inside a git repository (configless mode discovers members from git)", start.display() ) @@ -167,47 +218,76 @@ impl Workspace { /// coherent model exists (unreadable config or a dependency cycle). pub fn load_with_diagnostics(root: &Path) -> Result<(Option, Diagnostics)> { let mut diagnostics = Diagnostics::default(); - // The root's gleam.toml decides the mode: a [tools.trellis] table is - // configuration, its absence (or a missing manifest — a configless - // git-root workspace) means everything is defaulted and discovered. - let manifest_path = root.join(GLEAM_TOML); - let (configless, root_is_package) = match std::fs::read_to_string(&manifest_path) { - Ok(text) => match toml::from_str::(&text) { - Ok(document) => ( - !crate::config::has_trellis_table(&document), - document.get("name").is_some(), - ), + // A [tools.trellis] table at the root is configuration; its absence + // (or no root manifest at all — a configless git-root workspace) means + // everything is defaulted and discovered. Either config home may carry + // it, so both are read before deciding. + let mut carriers: Vec<&'static str> = Vec::new(); + let mut root_is_package = false; + for name in CONFIG_HOMES { + let path = root.join(name); + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + match toml::from_str::(&text) { + Ok(document) => { + if crate::config::has_trellis_table(&document) { + carriers.push(name); + } + // Only a gleam.toml can also be a package manifest. + root_is_package |= name == GLEAM_TOML && document.get("name").is_some(); + } Err(err) => { diagnostics.push( Finding::error( Check::WorkspaceConfig, - format!("failed to parse {}: {err}", manifest_path.display()), + format!("failed to parse {}: {err}", path.display()), ) - .at(GLEAM_TOML), + .at(name), ); return Ok((None, diagnostics)); } - }, - Err(_) => (true, false), - }; + } + } + if carriers.len() > 1 { + diagnostics.push( + Finding::error( + Check::WorkspaceConfig, + format!( + "both {} and {} carry a [tools.trellis] table; keep exactly one", + CONFIG_HOMES[0], CONFIG_HOMES[1] + ), + ) + .at(TRELLIS_TOML), + ); + return Ok((None, diagnostics)); + } + let configless = carriers.is_empty(); + let config_rel = carriers.first().copied().unwrap_or(GLEAM_TOML); let config = if configless { ConfigFile::configless() } else { - match ConfigFile::load(&manifest_path) { + match ConfigFile::load(&root.join(config_rel)) { Ok(config) => config, Err(err) => { diagnostics.push( - Finding::error(Check::WorkspaceConfig, format!("{err:#}")).at(GLEAM_TOML), + Finding::error(Check::WorkspaceConfig, format!("{err:#}")).at(config_rel), ); return Ok((None, diagnostics)); } } }; - report_unknown_config_keys(&config, &mut diagnostics); + // `gleam.toml`, or whatever the adapter names — the file whose presence + // marks a member directory and whose contents supply name and version. + let manifest_rel = config + .adapter + .as_ref() + .map_or(GLEAM_TOML, |adapter| adapter.manifest.as_str()); + report_unknown_config_keys(&config, config_rel, &mut diagnostics); let mut member_dirs = match &config.members { - Some(globs) => expand_member_globs(root, globs, &mut diagnostics), - None => discover_member_dirs(root, &mut diagnostics), + Some(globs) => expand_member_globs(root, globs, manifest_rel, &mut diagnostics), + None => discover_member_dirs(root, manifest_rel, &mut diagnostics), }; // A config-only root manifest ([tools.trellis] without a `name`) is // configuration, not a package — discovery must not sweep it in. @@ -223,7 +303,7 @@ impl Workspace { Check::WorkspaceConfig, format!("invalid `{task}` exclusion glob: {err:#}"), ) - .at(GLEAM_TOML), + .at(config_rel), ); } } @@ -233,7 +313,7 @@ impl Workspace { // match anything once it's done its job, so the typo check has to run // here, against the pre-filter candidates, not against the survivors. if let Some(patterns) = config.exclude.get(crate::config::MEMBERS_EXCLUDE_KEY) { - check_members_exclude_globs(root, &member_dirs, patterns, &mut diagnostics); + check_members_exclude_globs(root, &member_dirs, patterns, config_rel, &mut diagnostics); if let Ok(excludes) = build_globset(patterns) { member_dirs.retain(|dir| !excludes.is_match(rel_path_string(root, dir))); } @@ -247,7 +327,7 @@ impl Workspace { crate::config::MEMBERS_EXCLUDE_KEY ), ) - .at(GLEAM_TOML), + .at(config_rel), ); } @@ -263,7 +343,7 @@ impl Workspace { Check::WorkspaceConfig, format!("invalid release exclusion glob: {err:#}"), ) - .at(GLEAM_TOML), + .at(config_rel), ); }) .ok(); @@ -280,7 +360,7 @@ impl Workspace { Check::WorkspaceConfig, format!("invalid `package_tags_overrides` glob `{pattern}`: {err:#}"), ) - .at(GLEAM_TOML), + .at(config_rel), ); } } @@ -300,7 +380,7 @@ impl Workspace { "invalid `publish.lifecycle.packages` glob `{pattern}`: {err:#}" ), ) - .at(GLEAM_TOML), + .at(config_rel), ); } } @@ -308,25 +388,30 @@ impl Workspace { let mut members = Vec::new(); for dir in member_dirs { let rel_path = rel_path_string(root, &dir); - let manifest_path = dir.join("gleam.toml"); + let manifest_path = dir.join(manifest_rel); + let manifest_at = format!("{rel_path}/{manifest_rel}"); if !manifest_path.is_file() { diagnostics.push( Finding::error( Check::PackageManifest, - format!("member `{rel_path}` has no gleam.toml"), + format!("member `{rel_path}` has no {manifest_rel}"), ) - .at(format!("{rel_path}/{GLEAM_TOML}")), + .at(&manifest_at), ); continue; } - match GleamManifest::load(&manifest_path) { + let loaded = match &config.adapter { + Some(adapter) => crate::gleam::GleamManifest::load_adapted(&manifest_path, adapter), + None => GleamManifest::load(&manifest_path), + }; + match loaded { Ok(manifest) => { // A member manifest with its own [tools.trellis] would // hijack root discovery for commands run inside it. if manifest.has_trellis_config && dir != root { let message = if configless { format!( - "`{rel_path}/gleam.toml` has a [tools.trellis] table but the \ + "`{manifest_at}` has a [tools.trellis] table but the \ workspace root was inferred as `{}`; run trellis from \ `{rel_path}`, or move the table to the repository root", root.display() @@ -334,26 +419,48 @@ impl Workspace { } else { format!( "member `{rel_path}` has a [tools.trellis] table; only the \ - workspace root's gleam.toml may have one" + workspace root's {config_rel} may have one" ) }; diagnostics.push( Finding::error(Check::PackageManifest, message) - .at(format!("{rel_path}/{GLEAM_TOML}")) + .at(&manifest_at) .in_package(manifest.name.clone()), ); } + // A member-level `trellis.toml` would hijack root discovery + // for commands run from inside the member, exactly as a + // member-level [tools.trellis] table does. + if dir != root + && let Ok(text) = std::fs::read_to_string(dir.join(TRELLIS_TOML)) + && toml::from_str::(&text) + .is_ok_and(|document| crate::config::has_trellis_table(&document)) + { + diagnostics.push( + Finding::error( + Check::PackageManifest, + format!( + "member `{rel_path}` has a {TRELLIS_TOML} with a \ + [tools.trellis] table; only the workspace root may have one" + ), + ) + .at(format!("{rel_path}/{TRELLIS_TOML}")) + .in_package(manifest.name.clone()), + ); + } let lifecycle = resolve_lifecycle( &lifecycle_overrides, release_excludes.as_ref(), &rel_path, config.publish.lifecycle.default, + config_rel, &mut diagnostics, ); let tags = resolve_package_tags( &package_tags_overrides, &rel_path, &config.publish.package_tags, + config_rel, &mut diagnostics, ); members.push(Member { @@ -366,8 +473,7 @@ impl Workspace { }); } Err(err) => diagnostics.push( - Finding::error(Check::PackageManifest, format!("{err:#}")) - .at(format!("{rel_path}/{GLEAM_TOML}")), + Finding::error(Check::PackageManifest, format!("{err:#}")).at(&manifest_at), ), } } @@ -384,7 +490,7 @@ impl Workspace { member.name, other, member.rel_path ), ) - .at(format!("{}/{GLEAM_TOML}", member.rel_path)) + .at(format!("{}/{manifest_rel}", member.rel_path)) .in_package(member.name.clone()), ); } @@ -397,14 +503,14 @@ impl Workspace { Check::WorkspaceConfig, format!("`repository_tag_package` `{anchor}` is not a workspace member"), ) - .at(GLEAM_TOML), + .at(config_rel), ), Some(member) if !member.releasable() => diagnostics.push( Finding::error( Check::ReleaseBoundary, format!("`repository_tag_package` `{anchor}` is excluded from release"), ) - .at(GLEAM_TOML) + .at(config_rel) .in_package(&member.name), ), Some(_) => {} @@ -526,6 +632,7 @@ impl Workspace { root: root.to_path_buf(), config, configless, + config_rel, members, repository_series_anchor, deps, @@ -706,7 +813,11 @@ pub fn toposort( /// trellis, so a workspace using one still loads under a pinned older one; a /// deprecated key still configures what it always did, so failing on it would /// break working repositories for a spelling change. -fn report_unknown_config_keys(config: &ConfigFile, diagnostics: &mut Diagnostics) { +fn report_unknown_config_keys( + config: &ConfigFile, + config_rel: &str, + diagnostics: &mut Diagnostics, +) { for key in &config.deprecated_keys { diagnostics.push( Finding::warning( @@ -717,7 +828,7 @@ fn report_unknown_config_keys(config: &ConfigFile, diagnostics: &mut Diagnostics key.path, key.replacement ), ) - .at(GLEAM_TOML), + .at(config_rel), ); } for path in &config.unknown_keys { @@ -729,7 +840,7 @@ fn report_unknown_config_keys(config: &ConfigFile, diagnostics: &mut Diagnostics it may belong to a newer trellis" ), ) - .at(GLEAM_TOML), + .at(config_rel), ); } } @@ -742,6 +853,7 @@ fn check_members_exclude_globs( root: &Path, member_dirs: &[PathBuf], patterns: &[String], + config_rel: &str, diagnostics: &mut Diagnostics, ) { let rel_paths: Vec = member_dirs @@ -760,7 +872,7 @@ fn check_members_exclude_globs( "`@members` exclusion glob `{pattern}` matches no member (typo?)" ), ) - .at(GLEAM_TOML), + .at(config_rel), ); } } @@ -769,7 +881,7 @@ fn check_members_exclude_globs( Check::ExclusionGlob, format!("`@members` exclusion glob `{pattern}` is invalid"), ) - .at(GLEAM_TOML), + .at(config_rel), ), } } @@ -778,6 +890,7 @@ fn check_members_exclude_globs( fn expand_member_globs( root: &Path, patterns: &[String], + manifest_rel: &str, diagnostics: &mut Diagnostics, ) -> Vec { let mut dirs = BTreeSet::new(); @@ -793,9 +906,9 @@ fn expand_member_globs( continue; }; // A literal member path is a promise that a package lives there, so a - // missing gleam.toml stays a hard error downstream. A wildcard pattern + // missing manifest stays a hard error downstream. A wildcard pattern // sweeps directories that merely live alongside packages (node_modules, - // asset dirs), so matches without a gleam.toml are skipped. + // asset dirs), so matches without a manifest are skipped. let is_wildcard = pattern.contains(['*', '?', '[']); if is_wildcard { match glob::Pattern::new(full) { @@ -842,7 +955,7 @@ fn expand_member_globs( if entry .file_type() .is_some_and(|file_type| file_type.is_dir()) - && entry.path().join(GLEAM_TOML).is_file() => + && entry.path().join(manifest_rel).is_file() => { for (_, matcher, matched) in &mut wildcard_patterns { if matcher.matches_path_with(entry.path(), match_options) { @@ -874,12 +987,16 @@ fn expand_member_globs( dirs.into_iter().collect() } -/// Auto-discovery: every directory owning a non-gitignored `gleam.toml` is a +/// Auto-discovery: every directory owning a non-gitignored `manifest_rel` is a /// member. Gleam's `build/` tree is skipped unconditionally — it holds a /// manifest for every downloaded dependency, and while it is conventionally /// gitignored, membership must not hinge on that. -fn discover_member_dirs(root: &Path, diagnostics: &mut Diagnostics) -> Vec { - let manifests = match crate::git::ls_gleam_manifests(root) { +fn discover_member_dirs( + root: &Path, + manifest_rel: &str, + diagnostics: &mut Diagnostics, +) -> Vec { + let manifests = match crate::git::ls_manifests(root, manifest_rel) { Ok(manifests) => manifests, Err(err) => { diagnostics.push(Finding::error( @@ -891,18 +1008,22 @@ fn discover_member_dirs(root: &Path, diagnostics: &mut Diagnostics) -> Vec Vec Vec { let mut diagnostics = Diagnostics::default(); - discover_member_dirs(root, &mut diagnostics) + discover_member_dirs(root, GLEAM_TOML, &mut diagnostics) .iter() .filter(|dir| dir.as_path() != root) .map(|dir| rel_path_string(root, dir)) @@ -938,6 +1059,7 @@ fn resolve_package_tags( overrides: &[(Vec, globset::GlobMatcher)], rel_path: &str, default: &[TagLevel], + config_rel: &str, diagnostics: &mut Diagnostics, ) -> Vec { let mut matched: Vec<&Vec> = overrides @@ -970,7 +1092,7 @@ fn resolve_package_tags( .join(" and ") ), ) - .at(GLEAM_TOML), + .at(config_rel), ); default.to_vec() } @@ -993,6 +1115,7 @@ fn resolve_lifecycle( release_excludes: Option<&globset::GlobSet>, rel_path: &str, default: ReleaseLifecycle, + config_rel: &str, diagnostics: &mut Diagnostics, ) -> ReleaseLifecycle { let matched: Vec<(ReleaseLifecycle, &str)> = overrides @@ -1026,7 +1149,7 @@ fn resolve_lifecycle( .join(", "), ), ) - .at(GLEAM_TOML), + .at(config_rel), ); default } @@ -1134,11 +1257,23 @@ mod tests { let overrides = tag_overrides(&[("packages/lat_*", both)]); let default = [TagLevel::Exact]; assert_eq!( - resolve_package_tags(&overrides, "packages/cli", &default, &mut diagnostics), + resolve_package_tags( + &overrides, + "packages/cli", + &default, + GLEAM_TOML, + &mut diagnostics + ), default ); assert_eq!( - resolve_package_tags(&overrides, "packages/lat_core", &default, &mut diagnostics), + resolve_package_tags( + &overrides, + "packages/lat_core", + &default, + GLEAM_TOML, + &mut diagnostics + ), both ); assert!(!diagnostics.has_errors()); @@ -1156,6 +1291,7 @@ mod tests { &overrides, "packages/lat_core", &[TagLevel::Exact], + GLEAM_TOML, &mut diagnostics ), minor @@ -1171,8 +1307,13 @@ mod tests { ("packages/lat_*", &[TagLevel::Exact, TagLevel::Minor]), ]); let default = [TagLevel::Exact]; - let tags = - resolve_package_tags(&overrides, "packages/lat_core", &default, &mut diagnostics); + let tags = resolve_package_tags( + &overrides, + "packages/lat_core", + &default, + GLEAM_TOML, + &mut diagnostics, + ); assert_eq!(tags, default, "falls back to the default"); let errors: Vec<&str> = diagnostics.errors().collect(); assert_eq!(errors.len(), 1); @@ -1206,6 +1347,7 @@ mod tests { None, "packages/core", ReleaseLifecycle::Hex, + GLEAM_TOML, &mut diagnostics, ); assert_eq!(lifecycle, ReleaseLifecycle::Hex); @@ -1221,6 +1363,7 @@ mod tests { Some(&release_excludes), "examples/demo", ReleaseLifecycle::Hex, + GLEAM_TOML, &mut diagnostics, ); assert_eq!(lifecycle, ReleaseLifecycle::Workspace); @@ -1230,6 +1373,7 @@ mod tests { Some(&release_excludes), "packages/core", ReleaseLifecycle::Hex, + GLEAM_TOML, &mut diagnostics, ); assert_eq!(lifecycle, ReleaseLifecycle::Hex); @@ -1246,6 +1390,7 @@ mod tests { Some(&release_excludes), "examples/demo", ReleaseLifecycle::Hex, + GLEAM_TOML, &mut diagnostics, ); // Legacy alone would say `workspace`; the explicit rule wins. @@ -1265,6 +1410,7 @@ mod tests { None, "packages/providers/aws", ReleaseLifecycle::Hex, + GLEAM_TOML, &mut diagnostics, ); assert_eq!(lifecycle, ReleaseLifecycle::GitOnly); @@ -1283,6 +1429,7 @@ mod tests { None, "packages/special/thing", ReleaseLifecycle::Hex, + GLEAM_TOML, &mut diagnostics, ); assert_eq!( diff --git a/tests/adapter.rs b/tests/adapter.rs new file mode 100644 index 0000000..43929d5 --- /dev/null +++ b/tests/adapter.rs @@ -0,0 +1,364 @@ +//! End-to-end tests for `[tools.trellis.adapter]`: a workspace whose members +//! are not Gleam packages, configured from a `trellis.toml`. +//! +//! Two shapes are covered, matching the two motivating repositories — an APM +//! monorepo (`apm.yml`, YAML at the member root) and a Claude Code plugin +//! marketplace (`.claude-plugin/plugin.json`, JSON inside a hidden directory). + +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs; +use std::path::Path; + +fn trellis(dir: &Path) -> Command { + let mut cmd = Command::cargo_bin("trellis").unwrap(); + cmd.current_dir(dir); + cmd +} + +fn write(path: &Path, content: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); +} + +fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap() +} + +fn git_init(root: &Path) { + let status = std::process::Command::new("git") + .args(["init", "--quiet"]) + .current_dir(root) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "git init failed"); +} + +const TRELLIS_TOML: &str = r#"[tools.trellis] +members = ["packages/*"] + +[tools.trellis.adapter] +manifest = "apm.yml" + +[tools.trellis.publish] +package_tags = ["exact"] +"#; + +/// An `apm.yml` with the comments and layout a hand-maintained manifest has, +/// so a surgical bump has something to preserve. +fn apm_manifest(name: &str, version: &str) -> String { + format!( + "# The {name} package.\nname: {name}\nversion: \"{version}\" # bumped by trellis\n\ + dependencies:\n apm:\n - microsoft/apm-sample-package#v1.0.0\n" + ) +} + +/// A two-package APM workspace configured from `trellis.toml`. +fn scaffold(root: &Path) { + git_init(root); + write(&root.join("trellis.toml"), TRELLIS_TOML); + write( + &root.join("packages/alpha/apm.yml"), + &apm_manifest("alpha", "1.2.0"), + ); + write( + &root.join("packages/beta/apm.yml"), + &apm_manifest("beta", "0.3.1"), + ); +} + +// ---- discovery and introspection ------------------------------------------- + +#[test] +fn members_are_discovered_by_the_adapter_manifest() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + + // No gleam.toml anywhere: the root lives in trellis.toml, and identity + // comes from each member's apm.yml. `git_only` is the adapter default. + trellis(root) + .arg("list") + .assert() + .success() + .stdout("alpha git_only\nbeta git_only\n"); + + // A directory without the adapter manifest is not a member. + write(&root.join("packages/notes/README.md"), "not a package\n"); + trellis(root) + .arg("list") + .assert() + .success() + .stdout("alpha git_only\nbeta git_only\n"); +} + +#[test] +fn members_auto_discover_when_no_members_glob_is_configured() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + write( + &root.join("trellis.toml"), + "[tools.trellis]\n\n[tools.trellis.adapter]\nmanifest = \"apm.yml\"\n", + ); + + trellis(root) + .arg("list") + .assert() + .success() + .stdout("alpha git_only\nbeta git_only\n"); +} + +#[test] +fn commands_work_from_inside_a_member() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + + trellis(&root.join("packages/alpha")) + .args(["info", "alpha"]) + .assert() + .success() + .stdout(predicate::str::contains("1.2.0")); +} + +// ---- the release pipeline --------------------------------------------------- + +#[test] +fn version_apply_bumps_the_manifest_surgically() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + let alpha = root.join("packages/alpha/apm.yml"); + let before = read(&alpha); + + trellis(root) + .args([ + "changelog", + "new", + "--package", + "alpha", + "--kind", + "Added", + "--body", + "Support monorepo diffs", + ]) + .assert() + .success(); + trellis(root).args(["version", "apply"]).assert().success(); + + // Exactly one thing changed: comments, key order, and the quote style all + // survive, and the untouched sibling is byte-identical. + assert_eq!(read(&alpha), before.replace("1.2.0", "1.3.0")); + assert_eq!( + read(&root.join("packages/beta/apm.yml")), + apm_manifest("beta", "0.3.1") + ); + assert!(read(&root.join("packages/alpha/CHANGELOG.md")).contains("Support monorepo diffs")); + + trellis(root) + .args(["tag", "plan"]) + .assert() + .success() + .stdout(predicate::str::contains("alpha-v1.3.0")); +} + +#[test] +fn json_manifests_in_a_hidden_directory_work_the_same() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + git_init(root); + write( + &root.join("trellis.toml"), + "[tools.trellis]\nmembers = [\"plugins/*\"]\n\n[tools.trellis.adapter]\n\ + manifest = \".claude-plugin/plugin.json\"\n", + ); + let plugin = root.join("plugins/code-reviewer/.claude-plugin/plugin.json"); + let before = "{\n \"name\": \"code-reviewer\",\n \"version\": \"1.2.0\",\n \ + \"description\": \"Reviews code\"\n}\n"; + write(&plugin, before); + + trellis(root) + .arg("list") + .assert() + .success() + .stdout("code-reviewer git_only\n"); + + trellis(root) + .args([ + "changelog", + "new", + "--package", + "code-reviewer", + "--kind", + "Fixed", + "--body", + "Handle empty diffs", + ]) + .assert() + .success(); + trellis(root).args(["version", "apply"]).assert().success(); + + assert_eq!(read(&plugin), before.replace("1.2.0", "1.2.1")); +} + +// ---- what an adapter workspace refuses -------------------------------------- + +#[test] +fn gleam_only_commands_are_refused() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + + for args in [ + vec!["publish", "alpha"], + vec!["lockfile", "refresh"], + vec!["run", "test"], + vec!["new", "gamma"], + ] { + trellis(root) + .args(&args) + .assert() + .failure() + .stderr(predicate::str::contains("apm.yml")); + } +} + +#[test] +fn a_declared_task_still_runs_under_an_adapter() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + write( + &root.join("trellis.toml"), + &format!("{TRELLIS_TOML}\n[tools.trellis.tasks.check]\ncommand = \"echo checked\"\n"), + ); + + trellis(root) + .args(["run", "check"]) + .assert() + .success() + .stdout(predicate::str::contains("checked")); +} + +#[test] +fn a_hex_lifecycle_is_rejected_by_configuration() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + write( + &root.join("trellis.toml"), + &format!("{TRELLIS_TOML}\n[tools.trellis.publish.lifecycle]\ndefault = \"hex\"\n"), + ); + + trellis(root) + .arg("list") + .assert() + .failure() + .stderr(predicate::str::contains("has no Hex packages to publish")); +} + +// ---- doctor ----------------------------------------------------------------- + +#[test] +fn doctor_is_clean_and_names_the_adapter_checks() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + // A releasable member wants a CHANGELOG.md; seed both so the run is clean. + trellis(root).args(["doctor", "--fix"]).assert().success(); + + trellis(root) + .arg("doctor") + .assert() + .success() + .stdout(predicate::str::contains("parseable apm.yml")) + .stdout(predicate::str::contains("declares a semver version")) + // The Gleam-only checks are not claimed. + .stdout(predicate::str::contains("manifest.toml locked versions").not()) + .stdout(predicate::str::contains("gleam on PATH").not()); +} + +#[test] +fn doctor_reports_adapter_specific_problems() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + + write( + &root.join("packages/alpha/apm.yml"), + "name: renamed\nversion: not-a-version\n", + ); + trellis(root) + .args(["doctor", "--format", "json"]) + .assert() + .failure() + .stdout(predicate::str::contains("is not valid semver")) + .stdout(predicate::str::contains( + "the manifest name and the directory disagree", + )); + + // A manifest missing the field the adapter points at fails to load at all. + write(&root.join("packages/alpha/apm.yml"), "name: alpha\n"); + trellis(root) + .arg("list") + .assert() + .failure() + .stderr(predicate::str::contains( + "no `version` (the `adapter.version` field path)", + )); +} + +#[test] +fn a_member_level_trellis_toml_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + write( + &root.join("packages/alpha/trellis.toml"), + "[tools.trellis]\nmembers = [\"nope/*\"]\n", + ); + + trellis(root) + .arg("list") + .assert() + .failure() + .stderr(predicate::str::contains( + "only the workspace root may have one", + )); +} + +#[test] +fn init_refuses_a_repository_already_configured_by_trellis_toml() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + + // Writing a second config home is exactly the state `list` refuses below. + trellis(root) + .arg("init") + .assert() + .failure() + .stderr(predicate::str::contains("already a trellis workspace")) + .stderr(predicate::str::contains("trellis.toml")); + assert!(!root.join("gleam.toml").exists()); +} + +#[test] +fn two_config_homes_carrying_the_table_is_an_error() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + write( + &root.join("gleam.toml"), + "name = \"root\"\nversion = \"1.0.0\"\n\n[tools.trellis]\nmembers = [\"packages/*\"]\n", + ); + + trellis(root) + .arg("list") + .assert() + .failure() + .stderr(predicate::str::contains("keep exactly one")); +} diff --git a/tests/phase2.rs b/tests/phase2.rs index 7fae15a..e2d8407 100644 --- a/tests/phase2.rs +++ b/tests/phase2.rs @@ -1448,7 +1448,9 @@ fn version_apply_preflights_all_manifests_before_consuming_fragments() { .args(["version", "apply"]) .assert() .failure() - .stderr(predicate::str::contains("has no version field")); + .stderr(predicate::str::contains( + "packages/lat_mid/gleam.toml: no `version` field", + )); assert!(root.join(".changes/unreleased/lat_core-1.toml").is_file()); assert!(root.join(".changes/unreleased/lat_mid-1.toml").is_file()); diff --git a/website/src/content/docs/docs/configuration.mdx b/website/src/content/docs/docs/configuration.mdx index e8b8b93..b72c8b2 100644 --- a/website/src/content/docs/docs/configuration.mdx +++ b/website/src/content/docs/docs/configuration.mdx @@ -108,6 +108,75 @@ their roots. +## Manifest adapter + +Trellis is a Gleam tool: `gleam.toml` is where members, names, versions, and the +dependency graph come from. `[tools.trellis.adapter]` is the escape hatch for a +repository whose packages are not Gleam packages — an AI skill or Claude Code +plugin marketplace, an [APM](https://microsoft.github.io/apm/) monorepo, +anything distributed by git ref rather than by a registry. + +The adapter redefines three things: which file marks a member, and where its +name and version live inside that file. Everything downstream — the changelog +engine, `version plan`/`apply`, tags, GitHub Releases, `release pr`, `ci +matrix`/`outputs` — is the same code, unchanged. + +```toml title="trellis.toml" +[tools.trellis] +members = ["plugins/*"] + +[tools.trellis.adapter] +# Relative to each member directory. Its presence is what marks a member. +# The extension picks the format — .toml, .json, .yaml, or .yml. +manifest = ".claude-plugin/plugin.json" +# Dotted field paths. Both default to what is shown, so a manifest spelling +# them `name` and `version` needs neither key. +name = "name" +version = "version" + +[tools.trellis.publish] +package_tags = ["exact", "major"] # moving `v1` tags are how git-ref +exact_tag_format = "{name}-v{version}" # consumers pin a release +series_tag_format = "{name}-v{series}" +``` + +### `trellis.toml`, a second config home + +An adapter workspace usually has no `gleam.toml` for the table to live in, so +trellis also accepts a **`trellis.toml`** at the workspace root, carrying the +same `[tools.trellis]` table. Root discovery walks up to the first directory +with either file carrying the table, preferring `trellis.toml`; carrying it in +both at once is an error rather than a precedence puzzle. + +`trellis.toml` is not adapter-specific — a Gleam workspace may use it too — but +there is rarely a reason to, since `gleam.toml` is already there. + +### Version bumps are surgical + +`version apply` rewrites the single field `adapter.version` names and nothing +else. Comments, key order, indentation, and quote style all survive byte for +byte, because these manifests are hand-maintained files their owners read. A +field the path does not resolve to is an error, never an insertion. + +### What changes under an adapter + +| Area | Behavior | +| --- | --- | +| Discovery, `list`, `info`, `graph`, `ci matrix` | Unchanged — globs plus manifest presence. `members` may still be omitted to auto-discover. | +| Changelog, `version`, `tag`, `release pr`, GitHub Releases | Unchanged, except that the bump is written to the adapter's version field. | +| Dependency graph | Flat. An adapter declares no dependency edges, so topological order is member order and a workspace dependency never ripple-bumps. | +| `publish.lifecycle` | Defaults to `git_only`; `hex` anywhere is a configuration error. There is no registry to publish to. | +| `run` | Built-in verbs (`build`, `test`, …) are refused — they shell out to `gleam`. Declare what you need under `[tools.trellis.tasks]`; `trellis exec` is unaffected. | +| `publish`, `lockfile refresh`, `new` | Refused. | +| `doctor` | Drops the `manifest.toml`, shared-dependency, and toolchain checks; adds that every member manifest declares a semver version and a name matching its directory. | + + + +Keeping a root index file (a `marketplace.json`, say) in step with its members +is not yet automated — that is the `adapter.sync` work tracked separately. + + + ## Keys | Key | Required | What it does | @@ -117,6 +186,9 @@ their roots. | `exclude.@release` | no | The shared package set omitted from changelog, versioning, tagging, publishing, and release CI. The `@` prefix is reserved for special keys like this one, so it can never collide with a task name — task names may not start with `@`. | | `exclude.@members` | no | Directories removed from workspace membership entirely — never parsed, graphed, or touched by any command. Useful for committed test fixtures that auto-discovery would otherwise sweep in; also filters explicit `members` globs. | | `tasks.` | no | Custom tasks for `trellis run`. A task with a built-in's name (`build`, `test`, …) overrides it. `needs_deps = true` downloads dependencies first. | +| `adapter.manifest` | with the table | Path to the member manifest, relative to the member directory. Its presence marks a member, in place of a `gleam.toml`, and its extension picks the format: `.toml`, `.json`, `.yaml`, or `.yml`. See [Manifest adapter](#manifest-adapter). | +| `adapter.name` | no | Dot-separated field path to the member's name inside that manifest. Default: `name`. | +| `adapter.version` | no | Dot-separated field path to the member's version. This is the field `version apply` rewrites. Default: `version`. | | `publish.package_tags` | no | Which tags a release maintains per package, one entry per tag: `exact` (the whole version — `v1.2.3`), `major` (`v1`), `minor` (`v1.2`). Default: `["exact"]`. Must not be empty. | | `publish.package_tags_overrides` | no | Per-package overrides: a map of member-path glob to tag list. A member matched by globs resolving to different lists is an error; matches agreeing on the same list are fine. | | `publish.exact_tag_format` | no | Template the `exact` level substitutes into; `{name}` and `{version}`. Default: `{name}-v{version}`. | @@ -126,7 +198,7 @@ their roots. | `publish.repository_tags` | with the other two | Which repository tags to maintain, from the same vocabulary minus `exact`. Required alongside `repository_tag_package` and `repository_tag_format`; declare all three or none. | | `publish.path_dep_requirement` | no | How a workspace path dependency becomes a Hex requirement at publish time, from the dependency's current version `X.Y.Z`: `minor` (default, `>= X.Y.Z and < (X+1).0.0`), `patch` (`>= X.Y.Z and < X.(Y+1).0`), or `exact` (`== X.Y.Z`). | | `publish.retry` | no | Backoff for Hex rate limits: `{ attempts, initial_delay, multiplier }`. Defaults: 5 attempts, `30s`, multiplier 2. | -| `publish.lifecycle.default` | no | Release lifecycle for a member matched by no `packages` glob and no legacy `exclude.@release` glob: `workspace`, `git_only`, or `hex` (default). | +| `publish.lifecycle.default` | no | Release lifecycle for a member matched by no `packages` glob and no legacy `exclude.@release` glob: `workspace`, `git_only`, or `hex` (default). Under an [adapter](#manifest-adapter) the default is `git_only` and `hex` is an error. | | `publish.lifecycle.packages` | no | Per-package lifecycle overrides: a map of member-path glob to lifecycle. Takes precedence over `exclude.@release`. A member matched by globs resolving to different lifecycles is an error; matches agreeing on the same lifecycle are fine. | | `changelog.dir` | no | Where fragments and batched version sections live. Default: `.changes`. | | `changelog.kinds` | no | Change kinds and the version bump each implies. The largest bump among a package's unreleased fragments wins. Replacing the list replaces it entirely. | diff --git a/website/src/content/docs/docs/publishing.mdx b/website/src/content/docs/docs/publishing.mdx index 8baa5e2..747e7aa 100644 --- a/website/src/content/docs/docs/publishing.mdx +++ b/website/src/content/docs/docs/publishing.mdx @@ -118,7 +118,10 @@ releases, so neither `publish --tag` nor `ci tag-package` resolves them. `publish` selects members whose [release lifecycle](/docs/configuration/#release-lifecycle) is `hex` — `--package ` and `--tag ` refuse a `workspace` or `git_only` package by name, and `--all-untagged` only ever considers `hex` -members. It runs, per package and in dependency order: +members. In a [manifest adapter](/docs/configuration/#manifest-adapter) +workspace the command is refused outright: the lifecycle ladder tops out at +`git_only`, so there is nothing to publish. It runs, per package and in +dependency order: 1. **Idempotency check** — one Hex API query; versions already published are skipped.