spec_driven_docs/self_depend/
fragments.rs1use semver::Version;
8use serde::Serialize;
9
10use crate::self_depend::manager::Manager;
11use crate::self_depend::registry::CRATE_NAME;
12use crate::self_depend::venue::{Venue, Verdict, verdict};
13use crate::self_depend::{BINARY_NAME, ENVRC, SYNC_LINE, slug};
14
15pub const FLAKE_INPUT: &str = "spec-driven-docs";
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20pub struct Fragment {
21 pub file: String,
23 pub anchor: String,
25 pub placement: String,
27 pub text: String,
29}
30
31fn flake_ref(version: &Version) -> String {
33 format!("github:{}/v{version}", slug())
34}
35
36#[must_use]
38pub fn render(manager: Manager, venue: Venue, version: &Version) -> Vec<Fragment> {
39 if verdict(manager, venue) != Verdict::Renders {
40 return Vec::new();
41 }
42 match (manager, venue) {
43 (Manager::Flake, Venue::Flake) => vec![
44 Fragment {
45 file: "flake.nix".to_string(),
46 anchor: "inputs = {".to_string(),
47 placement: "inside the inputs attribute set".to_string(),
48 text: format!(
49 "{FLAKE_INPUT} = {{\n url = \"{}\";\n inputs.nixpkgs.follows = \"nixpkgs\";\n}};\n",
50 flake_ref(version)
51 ),
52 },
53 Fragment {
54 file: "flake.nix".to_string(),
55 anchor: "packages = [".to_string(),
56 placement: format!(
57 "inside the devshell's packages list, with `{FLAKE_INPUT}` added to the outputs arguments"
58 ),
59 text: format!("{FLAKE_INPUT}.packages.${{system}}.default\n"),
60 },
61 ],
62 (Manager::Mise, Venue::Crates) => vec![Fragment {
63 file: "mise.toml".to_string(),
64 anchor: "[tools]".to_string(),
65 placement: "under the tools table".to_string(),
66 text: format!("\"cargo:{CRATE_NAME}\" = \"{version}\"\n"),
67 }],
68 (Manager::Mise, Venue::GithubRelease) => vec![Fragment {
69 file: "mise.toml".to_string(),
70 anchor: "[tools]".to_string(),
71 placement: "under the tools table".to_string(),
72 text: format!(
73 "\"ubi:{}\" = {{ version = \"{version}\", exe = \"{BINARY_NAME}\" }}\n",
74 slug()
75 ),
76 }],
77 (Manager::Devbox, Venue::Flake) => vec![Fragment {
78 file: "devbox.json".to_string(),
79 anchor: "\"packages\": [".to_string(),
80 placement: "inside the packages array".to_string(),
81 text: format!("\"{}#default\"\n", flake_ref(version)),
82 }],
83 _ => Vec::new(),
84 }
85}
86
87#[must_use]
89pub fn envrc_fragment() -> Fragment {
90 Fragment {
91 file: ENVRC.to_string(),
92 anchor: "use flake".to_string(),
93 placement: "after the line that loads the environment".to_string(),
94 text: format!("{SYNC_LINE}\n"),
95 }
96}
97
98#[must_use]
100pub fn seed(manager: Manager, venue: Venue, version: &Version) -> Option<(String, String)> {
101 if verdict(manager, venue) != Verdict::Renders {
102 return None;
103 }
104 let file = manager.files().first()?.to_string();
105 let contents = match (manager, venue) {
106 (Manager::Flake, Venue::Flake) => format!(
107 "{{\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",
108 flake_ref(version)
109 ),
110 (Manager::Mise, _) => {
111 let entry = render(manager, venue, version)
112 .into_iter()
113 .map(|fragment| fragment.text)
114 .collect::<String>();
115 format!("[tools]\n{entry}")
116 }
117 (Manager::Devbox, Venue::Flake) => format!(
118 "{{\n \"packages\": [\n \"{}#default\"\n ]\n}}\n",
119 flake_ref(version)
120 ),
121 _ => return None,
122 };
123 Some((file, contents))
124}
125
126#[must_use]
128pub fn envrc_seed() -> String {
129 format!(
130 "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"
131 )
132}
133
134#[cfg(test)]
135mod tests {
136 #![allow(clippy::unwrap_used, reason = "a test panics as its failure signal")]
137
138 use super::*;
139 use crate::self_depend::pin;
140
141 fn version() -> Version {
142 "0.10.2".parse().unwrap()
143 }
144
145 #[test]
147 fn a_registry_fragment_names_the_crate_and_an_archive_fragment_the_binary() {
148 let registry = render(Manager::Mise, Venue::Crates, &version());
149 assert!(registry[0].text.contains(&format!("cargo:{CRATE_NAME}")));
150 assert!(!registry[0].text.contains(BINARY_NAME));
151 let archive = render(Manager::Mise, Venue::GithubRelease, &version());
152 assert!(
153 archive[0]
154 .text
155 .contains(&format!("exe = \"{BINARY_NAME}\""))
156 );
157 }
158
159 #[test]
160 fn a_manual_pair_renders_nothing_and_seeds_nothing() {
161 assert!(render(Manager::Asdf, Venue::Crates, &version()).is_empty());
162 assert!(seed(Manager::Flake, Venue::Crates, &version()).is_none());
163 }
164
165 #[test]
166 fn every_seed_reads_back_as_a_pin_at_the_seeded_version() {
167 for (manager, venue) in [
168 (Manager::Flake, Venue::Flake),
169 (Manager::Mise, Venue::Crates),
170 (Manager::Mise, Venue::GithubRelease),
171 (Manager::Devbox, Venue::Flake),
172 ] {
173 let (file, contents) = seed(manager, venue, &version()).unwrap();
174 assert_eq!(file, manager.files()[0]);
175 let held = pin::read(manager, &contents).unwrap();
176 assert_eq!(held.version, version(), "{manager} {venue}");
177 assert_eq!(held.venue, Some(venue));
178 }
179 assert_eq!(
180 pin::input_name(&seed(Manager::Flake, Venue::Flake, &version()).unwrap().1).as_deref(),
181 Some(FLAKE_INPUT)
182 );
183 }
184
185 #[test]
186 fn the_shell_loader_seed_carries_the_sync_line_once() {
187 assert_eq!(envrc_seed().matches(SYNC_LINE).count(), 1);
188 assert_eq!(envrc_fragment().text.trim(), SYNC_LINE);
189 }
190}