diff --git a/.cargo/audit.toml b/.cargo/audit.toml index f2f6f15..55eb7a8 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -16,7 +16,7 @@ ignore = [ # # atomic-polyfill 1.0.3 # └── heapless 0.7.17 - # └── postcard 1.1.3 (Steelbore-canonical binary cache format) + # └── postcard 1.1.3 (Spacecraft Software-canonical binary cache format) # # Postcard pins heapless 0.7 in its 1.x line. The fix is upstream # (heapless 0.8 → portable-atomic) which postcard adopts when its diff --git a/.gitignore b/.gitignore index b55e2ee..15d5a7e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,9 @@ Thumbs.db # Node (in case xtask or tooling pulls in npm later) node_modules/ + +# Publisher signing keypair. The secret key must NEVER be committed; the +# public half is baked into loran-core::pipeline::PUBLISHER_PUBLIC_KEY and +# documented in OPERATIONS.md, so neither file belongs in the tree. +loran-pages.key +loran-pages.pub diff --git a/AMBIGUOUS_REVIEW.md b/AMBIGUOUS_REVIEW.md index c4a93b8..84db2d0 100644 --- a/AMBIGUOUS_REVIEW.md +++ b/AMBIGUOUS_REVIEW.md @@ -6,7 +6,7 @@ SPDX-FileCopyrightText: 2026 Mohamed Hammad # Ambiguous Review — Steelbore → Spacecraft Software rename Generated alongside the mechanical rename pass described in -`/steelbore/steelbore/spacecraft-software-rename-prompt.md`. +`/spacecraft-software/spacecraft-software/spacecraft-software-rename-prompt.md`. ## Unresolved ambiguities (left as-is for human review) @@ -97,19 +97,29 @@ These cannot be done by a code-walking agent: shows the new URL. - [x] Update the local clone's `[remote "origin"]` URL in `.git/config`. **Done.** -- [ ] Register / DNS-configure `SpacecraftSoftware.org` and +- [x] Register / DNS-configure `SpacecraftSoftware.org` and `Loran.SpacecraftSoftware.org` (or whichever URL shape is - adopted from §1 above). -- [ ] Update the `homepage` and `repository` metadata for already- + adopted from §1 above). **Done.** +- [x] Update the `homepage` and `repository` metadata for already- published crates on crates.io (each crate's metadata can be republished via `cargo publish` once a version bump is cut; the v0.3.0 metadata on crates.io still points to the old URLs). -- [ ] Update GitHub Release notes attached to `v0.1.0-ingot`, + **Resolved** — workspace Cargo.toml already carries the new URLs + (`github.com/Spacecraft-Software/Loran`, + `Loran.SpacecraftSoftware.org`); all crates inherit via + `repository.workspace = true` / `homepage.workspace = true`. + Correct metadata will land on crates.io with the v0.4.0 publish. +- [x] Update GitHub Release notes attached to `v0.1.0-ingot`, `v0.2.0-billet`, `v0.3.0-bloom` if they reference the old org - URL (the release-notes Markdown lives outside the repo). -- [ ] Update any external project-registry entries (Lobste.rs, + URL (the release-notes Markdown lives outside the repo). **Done.** + Changes per release: + - ingot: `Forged in Steelbore.` × 2, `Steelbore default chain` → Spacecraft Software. + - billet: `Steelbore palette` × 2, `Forged in Steelbore.` → Spacecraft Software. + - bloom: `Steelbore-CLI self-documentation`, `github.com/Steelbore/Loran` × 2, + `Forged in Steelbore.` → Spacecraft Software. +- [x] Update any external project-registry entries (Lobste.rs, crates.io account profile, personal site) that point at the - old org or domain. + old org or domain. **Done.** - [ ] Update minisign trust policy / `OPERATIONS.md` channels if key - rotation is announced via a Steelbore-branded mailing list or + rotation is announced via a Spacecraft Software-branded mailing list or Matrix room that's also being renamed. diff --git a/OPERATIONS.md b/OPERATIONS.md index bebbc46..8b7f016 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -48,7 +48,7 @@ procedure must be planned well before the trust root has to change. | Constant in `loran-core::pipeline` | Used for | Notes | |------------------------------------|--------------------|----------------------------------| -| `PUBLISHER_PUBLIC_KEY` | Upstream pages tarball | Placeholder; replace with the real key at first launch (§2.4) | +| `PUBLISHER_PUBLIC_KEY` | Upstream pages tarball | Production `loran-pages` key (baked in-tree; ships with the next Loran release) | When parallel keys are active the `default_publisher` constructor returns both via the `LORAN_PAGES_PUBLIC_KEY` env var (comma-separated) @@ -220,13 +220,12 @@ the advisory is published before the new release ships. - `crates/loran-core/src/signing.rs::tests::TEST_PUBLIC_KEY` is a test-only key with a documented purpose. **It must never be the - active publisher key.** Phase 2 reuses the same string as the - placeholder `PUBLISHER_PUBLIC_KEY` precisely so an operator who - accidentally runs `loran update` against the placeholder URL gets a - clean diagnostic rather than a surprising decode failure. -- At first launch (§2.4), the release engineer must swap - `PUBLISHER_PUBLIC_KEY` to the real key and update - `signing::tests::TEST_PUBLIC_KEY` to remain distinct. + active publisher key.** It is now distinct from the production + `PUBLISHER_PUBLIC_KEY` (the first-launch swap is complete). +- The first-launch swap (§2.4) is **done**: `PUBLISHER_PUBLIC_KEY` + holds the production `loran-pages` key, distinct from + `signing::tests::TEST_PUBLIC_KEY`. Any future change to it is a key + rotation (§3 / §4), not a placeholder edit. ## 6. Release advisories diff --git a/crates/loran-core/pages/audio/pactl.md b/crates/loran-core/pages/audio/pactl.md new file mode 100644 index 0000000..e2072ca --- /dev/null +++ b/crates/loran-core/pages/audio/pactl.md @@ -0,0 +1,35 @@ ++++ +name = "pactl" +category = "audio" +summary = "PulseAudio control-protocol client. Drives sinks, sources, and modules on PulseAudio or pipewire-pulse." +replaces = [] +safe_alias_for = [] +pairs_with = ["wpctl"] +official = "https://www.freedesktop.org/wiki/Software/PulseAudio/" +tldr_page = "pactl" +written_in = "c" +since = "bravais@0.1" +tags = ["audio", "pulseaudio"] +aliases = [] ++++ + +## Spacecraft Software notes + +`pactl` speaks the PulseAudio control protocol. On a modern Spacecraft Software desktop that protocol is usually served by `pipewire-pulse`, so `pactl` and `wpctl` manipulate the *same* audio graph from two vocabularies. Prefer `wpctl` for native PipeWire work; reach for `pactl` when a tool, script, or upstream guide is written in PulseAudio terms. + +## Recommended usage + +```sh +pactl info # server, default sink/source +pactl list short sinks # enumerate outputs (name + index), tab-separated +pactl set-sink-volume @DEFAULT_SINK@ +5% # relative volume change +pactl set-sink-mute @DEFAULT_SINK@ toggle +pactl set-default-sink # route the default output +pactl set-sink-input-volume 80% # per-application volume +``` + +Use the `list short` forms in scripts — they are tab-separated and stable; the long forms are for humans. + +## Pairs with + +- **wpctl** — the native PipeWire controller for the same graph; canonical for `set-default` and routing on PipeWire. diff --git a/crates/loran-core/pages/audio/wpctl.md b/crates/loran-core/pages/audio/wpctl.md new file mode 100644 index 0000000..58e92c1 --- /dev/null +++ b/crates/loran-core/pages/audio/wpctl.md @@ -0,0 +1,34 @@ ++++ +name = "wpctl" +category = "audio" +summary = "WirePlumber control. Spacecraft Software default for PipeWire volume, default sinks, and routing." +replaces = [] +safe_alias_for = [] +pairs_with = ["pactl"] +official = "https://pipewire.pages.freedesktop.org/wireplumber/" +tldr_page = "wpctl" +written_in = "c" +since = "bravais@0.1" +tags = ["audio", "pipewire"] +aliases = [] ++++ + +## Spacecraft Software notes + +`wpctl` is the WirePlumber session-manager controller and the Spacecraft Software-canonical way to drive audio on a PipeWire stack. It speaks to PipeWire natively — no PulseAudio shim — so it sees every node, sink, and source the graph exposes. Reach for `pactl` only when a tool or muscle-memory expects PulseAudio command shapes. + +## Recommended usage + +```sh +wpctl status # the full graph: sinks, sources, streams +wpctl get-volume @DEFAULT_AUDIO_SINK@ # current default-output volume +wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+ # nudge the default output up 5% +wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle +wpctl set-default # make a sink/source the default (IDs from `status`) +``` + +`@DEFAULT_AUDIO_SINK@` / `@DEFAULT_AUDIO_SOURCE@` are stable aliases — bind them to media keys instead of hard-coding node IDs, which renumber across reboots. + +## Pairs with + +- **pactl** — the PulseAudio-compatible control surface over the same graph; use it when a script or guide is written against `pactl`. diff --git a/crates/loran-core/pages/bluetooth/bluetoothctl.md b/crates/loran-core/pages/bluetooth/bluetoothctl.md new file mode 100644 index 0000000..039fbd5 --- /dev/null +++ b/crates/loran-core/pages/bluetooth/bluetoothctl.md @@ -0,0 +1,35 @@ ++++ +name = "bluetoothctl" +category = "bluetooth" +summary = "BlueZ interactive client. Spacecraft Software default for scanning, pairing, and connecting devices." +replaces = [] +safe_alias_for = [] +pairs_with = ["btmgmt"] +official = "https://github.com/bluez/bluez" +tldr_page = "bluetoothctl" +written_in = "c" +since = "bravais@0.1" +tags = ["bluetooth", "bluez"] +aliases = [] ++++ + +## Spacecraft Software notes + +`bluetoothctl` is the interactive front-end to BlueZ over D-Bus and the Spacecraft Software-canonical tool for everyday pairing. It runs as a REPL, but every sub-command also works as a one-shot argument — which is what makes it scriptable. + +## Recommended usage + +```sh +bluetoothctl power on +bluetoothctl scan on # discover; `scan off` to stop +bluetoothctl devices # list known devices (MAC + name) +bluetoothctl pair AA:BB:CC:DD:EE:FF +bluetoothctl trust AA:BB:CC:DD:EE:FF # auto-reconnect on future boots +bluetoothctl connect AA:BB:CC:DD:EE:FF +``` + +For a guaranteed-clean pairing, `remove` the device first, then walk `scan on` → `pair` → `trust` → `connect`. + +## Pairs with + +- **btmgmt** — the lower-level, non-interactive management client; use it for adapter resets and scripted setup where a REPL is awkward. diff --git a/crates/loran-core/pages/bluetooth/btmgmt.md b/crates/loran-core/pages/bluetooth/btmgmt.md new file mode 100644 index 0000000..b6ed02d --- /dev/null +++ b/crates/loran-core/pages/bluetooth/btmgmt.md @@ -0,0 +1,36 @@ ++++ +name = "btmgmt" +category = "bluetooth" +summary = "BlueZ management-API client. Non-interactive adapter and device control for scripts and recovery." +replaces = [] +safe_alias_for = [] +pairs_with = ["bluetoothctl"] +official = "https://github.com/bluez/bluez" +# Empty: btmgmt has no tldr-pages entry, so this is the explicit +# "no tldr page" sentinel that disables the `loran show` tldr lookup. +tldr_page = "" +written_in = "c" +since = "bravais@0.1" +tags = ["bluetooth", "bluez"] +aliases = [] ++++ + +## Spacecraft Software notes + +`btmgmt` talks to the BlueZ management API directly rather than through the D-Bus agent layer that `bluetoothctl` uses. That makes it the right tool when the higher-level stack is wedged — toggling an adapter, forcing it discoverable, or driving setup from a non-interactive script. + +## Recommended usage + +```sh +btmgmt info # adapters and their current settings +btmgmt power on +btmgmt discov on # make the adapter discoverable +btmgmt find # scan for nearby devices +btmgmt pair -c AA:BB:CC:DD:EE:FF +``` + +`btmgmt` usually needs root — it holds the management socket. When pairing works here but not in `bluetoothctl`, an agent/policy issue in the session layer is the likely cause. + +## Pairs with + +- **bluetoothctl** — the interactive, agent-backed client for day-to-day pairing; `btmgmt` is the escape hatch beneath it. diff --git a/crates/loran-core/pages/categories.toml b/crates/loran-core/pages/categories.toml index e0a0530..c8dcbd1 100644 --- a/crates/loran-core/pages/categories.toml +++ b/crates/loran-core/pages/categories.toml @@ -46,3 +46,11 @@ description = "Shells, prompts, and small productivity tools that improve daily [data-processing] title = "Data processing" description = "JSON, YAML, CSV, and structured-data manipulation tools." + +[audio] +title = "Audio control" +description = "PipeWire / PulseAudio volume, routing, and output-device control." + +[bluetooth] +title = "Bluetooth" +description = "BlueZ device discovery, pairing, connection, and management clients." diff --git a/crates/loran-core/src/pipeline.rs b/crates/loran-core/src/pipeline.rs index 6b49827..8ab1fee 100644 --- a/crates/loran-core/src/pipeline.rs +++ b/crates/loran-core/src/pipeline.rs @@ -24,12 +24,11 @@ //! //! ## Publisher trust root //! -//! `PUBLISHER_PUBLIC_KEY` is **the** trust root for upstream tarballs. -//! Currently set to a development placeholder (see the constant's -//! doc-comment); the real publisher key lands when the upstream CDN -//! pipeline launches alongside Sub-phase 2D. Until then, callers who -//! want to exercise the pipeline against a staging publisher pass -//! their own key via the `public_key` parameter on [`update_pages`]. +//! `PUBLISHER_PUBLIC_KEY` is **the** trust root for upstream tarballs — +//! the production `loran-pages` publisher key. Callers who want to +//! exercise the pipeline against a staging publisher override it via +//! the `LORAN_PAGES_PUBLIC_KEY` env var (or the `public_keys` field on +//! [`UpdateOpts`]). use std::path::Path; @@ -72,25 +71,19 @@ pub const PUBLISHER_PAGES_TARBALL_URL: &str = /// See [`PUBLISHER_PAGES_MANIFEST_URL`]. pub const PUBLISHER_PAGES_SIG_URL: &str = "https://github.com/Spacecraft-Software/loran-pages/releases/latest/download/pages.tar.gz.minisig"; -/// Publisher's minisign public key. +/// Publisher's minisign public key — the production trust root for +/// upstream pages tarballs. /// -/// **Placeholder — development key, NOT for production use.** -/// -/// TODO(launch): replace with the real `loran-pages` publisher public -/// key before the first signed release. Generate the keypair with -/// `nix shell nixpkgs#minisign -c minisign -G`, paste the public half -/// here, and store the secret key + password as the `MINISIGN_SECRET_KEY` -/// / `MINISIGN_PASSWORD` secrets in the `loran-pages` repo (see -/// `OPERATIONS.md` → "Producing & publishing the pages tarball"). -/// Until then this constant is the same test key as -/// `signing::tests::TEST_PUBLIC_KEY`, embedded here so the -/// orchestration code compiles and unit tests can exercise the verify -/// step. +/// The `loran-pages` publisher key. Its secret half lives in the +/// release vault and the `loran-pages` repo's `MINISIGN_SECRET_KEY` / +/// `MINISIGN_PASSWORD` Actions secrets (see `OPERATIONS.md` §2). This +/// public half is intentionally distinct from +/// `signing::tests::TEST_PUBLIC_KEY` (`OPERATIONS.md` §5). /// /// Key rotation: a new Loran release ships a new value here. Older /// binaries fetching tarballs signed by a rotated key fail with /// [`UpdateError::Sign`]`(SignError::Mismatch)`. -pub const PUBLISHER_PUBLIC_KEY: &str = "RWQsvqYQlDxdL2X0KKUsxVNyWw9P0tBXOVJzTI0sD845q4PE5zlISFHM"; +pub const PUBLISHER_PUBLIC_KEY: &str = "RWQCDFFts6ow/eB1pNLK/soyo6Iwmye8rlrUf9RlRzQweEqZUC9xnay1"; /// Outcome of a single source update. #[derive(Debug, Clone, PartialEq, Eq, schemars::JsonSchema, serde::Serialize)] diff --git a/crates/loran-pages/src/error.rs b/crates/loran-pages/src/error.rs index de13119..b35ef26 100644 --- a/crates/loran-pages/src/error.rs +++ b/crates/loran-pages/src/error.rs @@ -67,4 +67,23 @@ pub enum PageError { /// Human-readable explanation of which well-formedness rule fired. reason: &'static str, }, + + /// `tldr_page`, when present, is structurally malformed. + /// + /// `tldr_page` names a page in the tldr-pages corpus (Spec §6.1). + /// tldr filenames are lowercase, hyphen-separated identifiers with no + /// extension, so a non-empty value may not contain whitespace or + /// uppercase letters, include a path separator, or carry a `.md` + /// suffix. An empty string is allowed — it is the explicit "no tldr + /// page" sentinel that disables the lookup. The `reason` payload + /// spells out which rule fired. This is a well-formedness check only — + /// it does **not** assert the page actually exists upstream (that + /// would require the tldr archive and break hermetic validation). + #[error("`tldr_page` is malformed (`{value}`): {reason}")] + InvalidTldrPage { + /// The rejected `tldr_page` string, verbatim. + value: String, + /// Human-readable explanation of which well-formedness rule fired. + reason: &'static str, + }, } diff --git a/crates/loran-pages/src/overlay.rs b/crates/loran-pages/src/overlay.rs index 350fb47..65335fa 100644 --- a/crates/loran-pages/src/overlay.rs +++ b/crates/loran-pages/src/overlay.rs @@ -18,7 +18,9 @@ use serde::{Deserialize, Serialize}; use crate::error::PageError; use crate::page::Page; -use crate::parse::{split_frontmatter, validate_category, validate_summary_length}; +use crate::parse::{ + split_frontmatter, validate_category, validate_summary_length, validate_tldr_page, +}; /// Partial overlay frontmatter. `name` is required (it's the merge /// key); everything else is `Some(_)` when the overlay sets the field @@ -72,8 +74,8 @@ impl OverlayPage { /// as [`Page::parse`], but every field except `name` may be absent. /// /// Field-level invariants are still enforced when the overlay does - /// set them (`summary` length, `category` shape) so a malformed - /// overlay file fails fast rather than at merge time. + /// set them (`summary` length, `category` shape, `tldr_page` shape) + /// so a malformed overlay file fails fast rather than at merge time. pub fn parse(input: &str) -> Result { let (frontmatter, body) = split_frontmatter(input)?; let raw: RawOverlay = toml::from_str(frontmatter)?; @@ -86,6 +88,9 @@ impl OverlayPage { if let Some(category) = raw.category.as_deref() { validate_category(category)?; } + if let Some(tldr_page) = raw.tldr_page.as_deref() { + validate_tldr_page(tldr_page)?; + } // safe_alias_for ⊆ replaces only validated when both are set in // the same overlay file (so it does not falsely reject an @@ -166,10 +171,10 @@ impl Page { /// /// - [`PageError::InvalidSafeAliasFor`] if the merged `safe_alias_for` /// is no longer a subset of the merged `replaces`. - /// - [`PageError::SummaryTooLong`] / [`PageError::InvalidCategory`] - /// if the overlay's replacement violates the per-field rule - /// (these are also checked at overlay-parse time, so the merge- - /// time check is a defense-in-depth.) + /// - [`PageError::SummaryTooLong`] / [`PageError::InvalidCategory`] / + /// [`PageError::InvalidTldrPage`] if the overlay's replacement + /// violates the per-field rule (these are also checked at overlay- + /// parse time, so the merge-time check is a defense-in-depth.) /// /// # Panics /// @@ -203,6 +208,7 @@ impl Page { self.official = Some(official); } if let Some(tldr_page) = overlay.tldr_page { + validate_tldr_page(&tldr_page)?; self.tldr_page = Some(tldr_page); } if let Some(tags) = overlay.tags { @@ -347,6 +353,31 @@ mod tests { ); } + #[test] + fn overlay_validates_tldr_page_shape_at_parse_time() { + let src = "+++\nname = \"x\"\ntldr_page = \"eza.md\"\n+++\n"; + let err = OverlayPage::parse(src).unwrap_err(); + assert!( + matches!(err, PageError::InvalidTldrPage { .. }), + "got {err:?}" + ); + } + + #[test] + fn merge_validates_replacement_tldr_page() { + // An overlay that swaps in a malformed `tldr_page` is rejected at + // merge time even if it somehow bypassed parse-time validation. + let overlay = OverlayPage { + tldr_page: Some("Bad Name".to_owned()), + ..OverlayPage::parse("+++\nname = \"eza\"\n+++\n").unwrap() + }; + let err = base_eza().merge_overlay(overlay).unwrap_err(); + assert!( + matches!(err, PageError::InvalidTldrPage { .. }), + "got {err:?}" + ); + } + #[test] fn safe_alias_subset_violation_caught_post_merge() { // Base sets replaces=[ls]; overlay sets safe_alias_for=[dir] diff --git a/crates/loran-pages/src/parse.rs b/crates/loran-pages/src/parse.rs index 27930ba..3186032 100644 --- a/crates/loran-pages/src/parse.rs +++ b/crates/loran-pages/src/parse.rs @@ -27,6 +27,7 @@ impl Page { /// 4. `summary` length cap (or [`PageError::SummaryTooLong`]). /// 5. `category` well-formedness (or [`PageError::InvalidCategory`]). /// 6. `safe_alias_for ⊆ replaces` (or [`PageError::InvalidSafeAliasFor`]). + /// 7. `tldr_page` well-formedness, when set (or [`PageError::InvalidTldrPage`]). /// /// # Examples /// @@ -126,6 +127,10 @@ fn validate(raw: RawPage, body: &str) -> Result { } } + if let Some(tldr_page) = &raw.tldr_page { + validate_tldr_page(tldr_page)?; + } + Ok(Page { name, category, @@ -178,6 +183,52 @@ pub(crate) fn validate_category(value: &str) -> Result<(), PageError> { Ok(()) } +/// Enforce `tldr_page` well-formedness per Spec §6.1. Shared between +/// [`Page::parse`] and [`crate::OverlayPage::parse`]; only called when +/// the field is present (it is optional). +/// +/// The rules are deliberately conservative — they reject the realistic +/// authoring mistakes (a copied filename like `eza.md`, a display name +/// like `git commit`, a stray capital) without rejecting legitimate tldr +/// page names that contain `+`, `.`, or digits (`g++`, `7z`, `2to3`). +/// This is a *format* check; it does not verify the page exists in the +/// tldr-pages corpus, which would require the archive and break hermetic +/// validation. +/// +/// An **empty string is permitted**: it is the explicit "this tool has no +/// tldr page" sentinel that disables the tldr lookup (see +/// [`crate::Page::tldr_page`] usage in `loran-core::show`). Only *non-empty* +/// values are held to the format rules below. +pub(crate) fn validate_tldr_page(value: &str) -> Result<(), PageError> { + let make_err = |reason: &'static str| PageError::InvalidTldrPage { + value: value.to_owned(), + reason, + }; + + if value.chars().any(char::is_whitespace) { + return Err(make_err("must not contain whitespace")); + } + if value.chars().any(|c| c.is_ascii_uppercase()) { + return Err(make_err("must be lowercase")); + } + if value.contains('/') || value.contains('\\') { + return Err(make_err("must not contain a path separator")); + } + // Reject a trailing `.md` (a copied filename) case-insensitively via + // `Path::extension` — `str::ends_with(".md")` trips clippy's + // `case_sensitive_file_extension_comparisons` and would miss `.MD`. + if std::path::Path::new(value) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("md")) + { + return Err(make_err( + "must be the page name, without the `.md` extension", + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use crate::Page; @@ -456,6 +507,85 @@ safe_alias_for = [\"less\"] ); } + #[test] + fn parses_well_formed_tldr_page() { + let src = "+++\nname = \"x\"\ncategory = \"c\"\nsummary = \"s\"\ntldr_page = \"git-cliff\"\n+++\n"; + let page = Page::parse(src).expect("hyphenated tldr_page is well-formed"); + assert_eq!(page.tldr_page.as_deref(), Some("git-cliff")); + } + + #[test] + fn parses_tldr_page_with_punctuation_and_digits() { + // `g++`, `7z`, `2to3` are real tldr pages — `+`, leading digits, + // and embedded digits must all be accepted by the format check. + for name in ["g++", "7z", "2to3"] { + let src = format!( + "+++\nname = \"x\"\ncategory = \"c\"\nsummary = \"s\"\ntldr_page = \"{name}\"\n+++\n" + ); + assert!(Page::parse(&src).is_ok(), "tldr_page `{name}` should parse"); + } + } + + #[test] + fn omitted_tldr_page_is_allowed() { + // The field is optional; a page without it parses cleanly. + let src = "+++\nname = \"x\"\ncategory = \"c\"\nsummary = \"s\"\n+++\n"; + let page = Page::parse(src).expect("tldr_page is optional"); + assert_eq!(page.tldr_page, None); + } + + #[test] + fn empty_tldr_page_is_allowed_as_disable_sentinel() { + // An explicit empty `tldr_page` is the documented "this tool has + // no tldr page" sentinel that disables the lookup (see + // `loran-core::show`). It must parse, not be rejected as malformed. + let src = "+++\nname = \"x\"\ncategory = \"c\"\nsummary = \"s\"\ntldr_page = \"\"\n+++\n"; + let page = Page::parse(src).expect("empty tldr_page is a valid sentinel"); + assert_eq!(page.tldr_page.as_deref(), Some("")); + } + + #[test] + fn error_tldr_page_uppercase() { + let src = + "+++\nname = \"x\"\ncategory = \"c\"\nsummary = \"s\"\ntldr_page = \"GitCliff\"\n+++\n"; + let err = Page::parse(src).unwrap_err(); + assert!( + matches!(err, PageError::InvalidTldrPage { .. }), + "got {err:?}" + ); + } + + #[test] + fn error_tldr_page_whitespace() { + let src = "+++\nname = \"x\"\ncategory = \"c\"\nsummary = \"s\"\ntldr_page = \"git commit\"\n+++\n"; + let err = Page::parse(src).unwrap_err(); + assert!( + matches!(err, PageError::InvalidTldrPage { .. }), + "got {err:?}" + ); + } + + #[test] + fn error_tldr_page_path_separator() { + let src = "+++\nname = \"x\"\ncategory = \"c\"\nsummary = \"s\"\ntldr_page = \"common/eza\"\n+++\n"; + let err = Page::parse(src).unwrap_err(); + assert!( + matches!(err, PageError::InvalidTldrPage { .. }), + "got {err:?}" + ); + } + + #[test] + fn error_tldr_page_md_extension() { + let src = + "+++\nname = \"x\"\ncategory = \"c\"\nsummary = \"s\"\ntldr_page = \"eza.md\"\n+++\n"; + let err = Page::parse(src).unwrap_err(); + assert!( + matches!(err, PageError::InvalidTldrPage { .. }), + "got {err:?}" + ); + } + #[test] fn unknown_frontmatter_field_is_rejected_as_invalid_toml() { // Unknown fields trigger serde(deny_unknown_fields), which serde diff --git a/crates/loran/src/cmd/validate.rs b/crates/loran/src/cmd/validate.rs index 29c44b9..63fed75 100644 --- a/crates/loran/src/cmd/validate.rs +++ b/crates/loran/src/cmd/validate.rs @@ -256,6 +256,7 @@ fn classify(err: &PageError, text: &str) -> (&'static str, u32) { line_of_key(text, "safe_alias_for"), ), PageError::InvalidCategory { .. } => ("INVALID_CATEGORY", line_of_key(text, "category")), + PageError::InvalidTldrPage { .. } => ("INVALID_TLDR_PAGE", line_of_key(text, "tldr_page")), } } diff --git a/crates/loran/tests/snapshots/snapshots__snapshot_categories_text_output.snap b/crates/loran/tests/snapshots/snapshots__snapshot_categories_text_output.snap index b65ad10..05a893b 100644 --- a/crates/loran/tests/snapshots/snapshots__snapshot_categories_text_output.snap +++ b/crates/loran/tests/snapshots/snapshots__snapshot_categories_text_output.snap @@ -2,6 +2,8 @@ source: crates/loran/tests/snapshots.rs expression: normalise(&stdout) --- +audio Audio control 2 +bluetooth Bluetooth 2 data-processing Data processing 4 file-listing File listing 3 file-search File search 2 diff --git a/crates/loran/tests/snapshots/snapshots__snapshot_list_default_text_output.snap b/crates/loran/tests/snapshots/snapshots__snapshot_list_default_text_output.snap index 90010fc..dbe6565 100644 --- a/crates/loran/tests/snapshots/snapshots__snapshot_list_default_text_output.snap +++ b/crates/loran/tests/snapshots/snapshots__snapshot_list_default_text_output.snap @@ -4,8 +4,10 @@ expression: normalise(&stdout) --- bandwhich system-monitoring Per-process and per-connection bandwidth monitor for the terminal. bat file-viewing cat with syntax highlighting, line numbers, and Git integration. +bluetoothctl bluetooth BlueZ interactive client. Spacecraft Software default for scanning, pairing, and connecting devices. bottom system-monitoring Cross-platform graphical process and system monitor (TUI). broot file-listing Interactive TUI for navigating large directory trees with live fuzzy filtering. +btmgmt bluetooth BlueZ management-API client. Non-interactive adapter and device control for scripts and recovery. dasel data-processing Multi-format query and edit — JSON, YAML, TOML, XML — one selector syntax. delta file-viewing Syntax-highlighting pager for git, diff, and grep output. Side-by-side and unified. direnv shell-utilities Per-directory environment variables. `.envrc` activates on cd, deactivates on leave. @@ -22,9 +24,11 @@ just shell-utilities Modern command runner. `justfile` recipes with arguments, d lazygit version-control Git TUI with stage-hunk-by-hunk, interactive rebase, and one-key commands. lsd file-listing Drop-in `ls` replacement with colours and icons. Conservative alternative to eza. miller data-processing awk for CSV / TSV / JSON / Parquet — name-keyed, schema-aware, streaming. +pactl audio PulseAudio control-protocol client. Drives sinks, sources, and modules on PulseAudio or pipewire-pulse. procs process-management Modern ps replacement with colour, search, and tree view. rg text-search ripgrep — recursively search files for a regex pattern. The Spacecraft Software default. sd text-search Intuitive find-and-replace CLI. Literal patterns by default, regex with -r. starship shell-utilities Cross-shell prompt. One TOML config drives Bash, Zsh, Fish, Nushell, PowerShell, Ion. +wpctl audio WirePlumber control. Spacecraft Software default for PipeWire volume, default sinks, and routing. xh networking Friendly HTTP client. Re-implementation of HTTPie's UX in Rust with extra polish. zoxide shell-utilities Smart `cd` that learns. Type a fragment of any visited directory and jump there. diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5338bc0 --- /dev/null +++ b/flake.nix @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 Mohamed Hammad +{ + description = "loran — agent-native reference manual for Spacecraft Software"; + + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + + outputs = + { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + in + { + packages.${system}.default = pkgs.rustPlatform.buildRustPackage { + pname = "loran"; + version = "0-unstable"; + src = ./.; + cargoLock.lockFile = ./Cargo.lock; + + # Build only the loran binary crate (excludes xtask, which is dev-only). + cargoBuildFlags = [ "-p" "loran" ]; + cargoTestFlags = [ "-p" "loran" ]; + + meta = { + description = "Agent-native reference manual for Spacecraft Software"; + homepage = "https://github.com/Spacecraft-Software/Loran"; + license = pkgs.lib.licenses.gpl3Plus; + mainProgram = "loran"; + }; + }; + + apps.${system}.default = { + type = "app"; + program = "${self.packages.${system}.default}/bin/loran"; + }; + }; +}