spec-driven-docs 0.12.0

Spec-driven documentation: current specs, immutable decision records, and executable gates kept coherent for people and coding agents.
Documentation
//! The fragments a rendering pair serves, and the files a seed writes.
//!
//! A fragment carries its anchor and its placement so a person or an agent
//! places it in a file the project owns. A seed is a whole file, written
//! only where the project has none.

use semver::Version;
use serde::Serialize;

use crate::self_depend::manager::Manager;
use crate::self_depend::registry::CRATE_NAME;
use crate::self_depend::venue::{Venue, Verdict, verdict};
use crate::self_depend::{BINARY_NAME, ENVRC, SYNC_LINE, slug};

/// The name the flake input and the devshell reference share.
pub const FLAKE_INPUT: &str = "spec-driven-docs";

/// One fragment, with where it goes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Fragment {
    /// The file the fragment belongs in, relative to the project root.
    pub file: String,
    /// The line the fragment is placed near.
    pub anchor: String,
    /// Where, relative to the anchor.
    pub placement: String,
    /// The fragment.
    pub text: String,
}

/// The flake reference for one tag.
fn flake_ref(version: &Version) -> String {
    format!("github:{}/v{version}", slug())
}

/// The fragments one rendering pair needs, or none for a manual pair.
#[must_use]
pub fn render(manager: Manager, venue: Venue, version: &Version) -> Vec<Fragment> {
    if verdict(manager, venue) != Verdict::Renders {
        return Vec::new();
    }
    match (manager, venue) {
        (Manager::Flake, Venue::Flake) => vec![
            Fragment {
                file: "flake.nix".to_string(),
                anchor: "inputs = {".to_string(),
                placement: "inside the inputs attribute set".to_string(),
                text: format!(
                    "{FLAKE_INPUT} = {{\n  url = \"{}\";\n  inputs.nixpkgs.follows = \"nixpkgs\";\n}};\n",
                    flake_ref(version)
                ),
            },
            Fragment {
                file: "flake.nix".to_string(),
                anchor: "packages = [".to_string(),
                placement: format!(
                    "inside the devshell's packages list, with `{FLAKE_INPUT}` added to the outputs arguments"
                ),
                text: format!("{FLAKE_INPUT}.packages.${{system}}.default\n"),
            },
        ],
        (Manager::Mise, Venue::Crates) => vec![Fragment {
            file: "mise.toml".to_string(),
            anchor: "[tools]".to_string(),
            placement: "under the tools table".to_string(),
            text: format!("\"cargo:{CRATE_NAME}\" = \"{version}\"\n"),
        }],
        (Manager::Mise, Venue::GithubRelease) => vec![Fragment {
            file: "mise.toml".to_string(),
            anchor: "[tools]".to_string(),
            placement: "under the tools table".to_string(),
            text: format!(
                "\"ubi:{}\" = {{ version = \"{version}\", exe = \"{BINARY_NAME}\" }}\n",
                slug()
            ),
        }],
        (Manager::Devbox, Venue::Flake) => vec![Fragment {
            file: "devbox.json".to_string(),
            anchor: "\"packages\": [".to_string(),
            placement: "inside the packages array".to_string(),
            text: format!("\"{}#default\"\n", flake_ref(version)),
        }],
        _ => Vec::new(),
    }
}

/// The shell-entry line, as a fragment.
#[must_use]
pub fn envrc_fragment() -> Fragment {
    Fragment {
        file: ENVRC.to_string(),
        anchor: "use flake".to_string(),
        placement: "after the line that loads the environment".to_string(),
        text: format!("{SYNC_LINE}\n"),
    }
}

/// The whole manager file a seed writes for one rendering pair.
#[must_use]
pub fn seed(manager: Manager, venue: Venue, version: &Version) -> Option<(String, String)> {
    if verdict(manager, venue) != Verdict::Renders {
        return None;
    }
    let file = manager.files().first()?.to_string();
    let contents = match (manager, venue) {
        (Manager::Flake, Venue::Flake) => format!(
            "{{\n  description = \"Development shell\";\n\n  inputs = {{\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    {FLAKE_INPUT} = {{\n      # Moved by `{SYNC_LINE}` from {ENVRC}: the tag in this URL is the\n      # version, and flake.lock is the content pin.\n      url = \"{}\";\n      inputs.nixpkgs.follows = \"nixpkgs\";\n    }};\n  }};\n\n  outputs =\n    {{ nixpkgs, {FLAKE_INPUT}, ... }}:\n    let\n      system = \"x86_64-linux\";\n      pkgs = nixpkgs.legacyPackages.${{system}};\n    in\n    {{\n      devShells.${{system}}.default = pkgs.mkShell {{\n        packages = [\n          {FLAKE_INPUT}.packages.${{system}}.default\n        ];\n      }};\n    }};\n}}\n",
            flake_ref(version)
        ),
        (Manager::Mise, _) => {
            let entry = render(manager, venue, version)
                .into_iter()
                .map(|fragment| fragment.text)
                .collect::<String>();
            format!("[tools]\n{entry}")
        }
        (Manager::Devbox, Venue::Flake) => format!(
            "{{\n  \"packages\": [\n    \"{}#default\"\n  ]\n}}\n",
            flake_ref(version)
        ),
        _ => return None,
    };
    Some((file, contents))
}

/// The whole shell loader a seed writes, for the flake pair alone.
#[must_use]
pub fn envrc_seed() -> String {
    format!(
        "use flake\n\n# Move the {CRATE_NAME} pin to its latest release. The verb owns the\n# transaction over flake.nix and flake.lock, attempts at most once a day,\n# stays silent, exits 0 on every outcome, and leaves a diff for review.\n{SYNC_LINE}\n"
    )
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used, reason = "a test panics as its failure signal")]

    use super::*;
    use crate::self_depend::pin;

    fn version() -> Version {
        "0.10.2".parse().unwrap()
    }

    /// VERIFIES acquisition:a-fragment-names-the-venue-form
    #[test]
    fn a_registry_fragment_names_the_crate_and_an_archive_fragment_the_binary() {
        let registry = render(Manager::Mise, Venue::Crates, &version());
        assert!(registry[0].text.contains(&format!("cargo:{CRATE_NAME}")));
        assert!(!registry[0].text.contains(BINARY_NAME));
        let archive = render(Manager::Mise, Venue::GithubRelease, &version());
        assert!(
            archive[0]
                .text
                .contains(&format!("exe = \"{BINARY_NAME}\""))
        );
    }

    #[test]
    fn a_manual_pair_renders_nothing_and_seeds_nothing() {
        assert!(render(Manager::Asdf, Venue::Crates, &version()).is_empty());
        assert!(seed(Manager::Flake, Venue::Crates, &version()).is_none());
    }

    #[test]
    fn every_seed_reads_back_as_a_pin_at_the_seeded_version() {
        for (manager, venue) in [
            (Manager::Flake, Venue::Flake),
            (Manager::Mise, Venue::Crates),
            (Manager::Mise, Venue::GithubRelease),
            (Manager::Devbox, Venue::Flake),
        ] {
            let (file, contents) = seed(manager, venue, &version()).unwrap();
            assert_eq!(file, manager.files()[0]);
            let held = pin::read(manager, &contents).unwrap();
            assert_eq!(held.version, version(), "{manager} {venue}");
            assert_eq!(held.venue, Some(venue));
        }
        assert_eq!(
            pin::input_name(&seed(Manager::Flake, Venue::Flake, &version()).unwrap().1).as_deref(),
            Some(FLAKE_INPUT)
        );
    }

    #[test]
    fn the_shell_loader_seed_carries_the_sync_line_once() {
        assert_eq!(envrc_seed().matches(SYNC_LINE).count(), 1);
        assert_eq!(envrc_fragment().text.trim(), SYNC_LINE);
    }
}