Skip to main content

asimov_module_kit/module/
manifest_edit.rs

1// This is free and unencumbered software released into the public domain.
2
3//! A narrow text-based edit for `.asimov/module.yaml`, deliberately not a
4//! full YAML parse+reserialize: every real manifest opens with a `# See:
5//! ...` comment and represents empty `handles:` fields as bare keys, and a
6//! serde round-trip would silently drop the comment and reformat those
7//! fields — an unacceptable blast radius for what should be a one-line
8//! insertion.
9
10use alloc::{
11    format,
12    string::{String, ToString},
13    vec::Vec,
14};
15use std::{fs, io, path::Path};
16use thiserror::Error;
17
18#[derive(Debug, Error)]
19pub enum ManifestEditError {
20    #[error("`provides.programs` block not found in expected shape in {0}")]
21    UnexpectedShape(std::path::PathBuf),
22
23    #[error(transparent)]
24    Io(#[from] io::Error),
25}
26
27/// Appends `program_name` as a new item under `provides.programs:` in an
28/// `.asimov/module.yaml` file, leaving every other line byte-identical.
29pub fn append_provides_program(
30    manifest_path: &Path,
31    program_name: &str,
32) -> Result<(), ManifestEditError> {
33    let contents = fs::read_to_string(manifest_path)?;
34    let updated = insert_program_line(&contents, program_name)
35        .ok_or_else(|| ManifestEditError::UnexpectedShape(manifest_path.to_path_buf()))?;
36    fs::write(manifest_path, updated)?;
37    Ok(())
38}
39
40fn insert_program_line(contents: &str, program_name: &str) -> Option<String> {
41    let lines: Vec<&str> = contents.lines().collect();
42
43    let provides_idx = lines.iter().position(|line| *line == "provides:")?;
44    let programs_idx = lines
45        .iter()
46        .enumerate()
47        .skip(provides_idx + 1)
48        .take_while(|(_, line)| line.starts_with(' ') || line.is_empty())
49        .find(|(_, line)| line.trim_start() == "programs:")
50        .map(|(i, _)| i)?;
51
52    let programs_line = lines[programs_idx];
53    let base_indent = programs_line.len() - programs_line.trim_start().len();
54    let indent = " ".repeat(base_indent + 2);
55    let mut insert_at = programs_idx + 1;
56    while lines
57        .get(insert_at)
58        .is_some_and(|line| line.trim_start().starts_with("- "))
59    {
60        insert_at += 1;
61    }
62
63    let mut result: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
64    result.insert(insert_at, format!("{indent}- {program_name}"));
65
66    let newline = if contents.contains("\r\n") {
67        "\r\n"
68    } else {
69        "\n"
70    };
71    let mut joined = result.join(newline);
72    if contents.ends_with('\n') {
73        joined.push_str(newline);
74    }
75    Some(joined)
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use tempfile::tempdir;
82
83    const MANIFEST: &str = r#"# See: https://asimov-specs.github.io/module-manifest/
84---
85name: widget
86label: Widget
87title: ASIMOV Widget Module
88summary: ASIMOV module.
89links:
90  - https://github.com/asimov-modules/asimov-widget-module
91
92provides:
93  programs:
94    - asimov-widget-emitter
95
96handles:
97  url_protocols:
98  url_prefixes:
99  url_patterns:
100  file_extensions:
101  content_types:
102"#;
103
104    #[test]
105    fn appends_after_existing_program() {
106        let dir = tempdir().unwrap();
107        let path = dir.path().join("module.yaml");
108        fs::write(&path, MANIFEST).unwrap();
109
110        append_provides_program(&path, "asimov-widget-fetcher").unwrap();
111
112        let updated = fs::read_to_string(&path).unwrap();
113        let expected = MANIFEST.replace(
114            "    - asimov-widget-emitter\n",
115            "    - asimov-widget-emitter\n    - asimov-widget-fetcher\n",
116        );
117        assert_eq!(updated, expected);
118    }
119
120    #[test]
121    fn appends_to_empty_program_list() {
122        let dir = tempdir().unwrap();
123        let path = dir.path().join("module.yaml");
124        let manifest = MANIFEST.replace("    - asimov-widget-emitter\n", "");
125        fs::write(&path, &manifest).unwrap();
126
127        append_provides_program(&path, "asimov-widget-emitter").unwrap();
128
129        let updated = fs::read_to_string(&path).unwrap();
130        assert_eq!(updated, MANIFEST);
131    }
132
133    #[test]
134    fn preserves_everything_else_byte_for_byte() {
135        let dir = tempdir().unwrap();
136        let path = dir.path().join("module.yaml");
137        fs::write(&path, MANIFEST).unwrap();
138
139        append_provides_program(&path, "asimov-widget-fetcher").unwrap();
140
141        let updated = fs::read_to_string(&path).unwrap();
142        for line in MANIFEST.lines() {
143            assert!(updated.contains(line), "missing original line: {line:?}");
144        }
145        assert!(updated.starts_with("# See: https://asimov-specs.github.io/module-manifest/\n"));
146    }
147
148    #[test]
149    fn derives_indent_from_the_programs_line() {
150        let dir = tempdir().unwrap();
151        let path = dir.path().join("module.yaml");
152        // `programs:` indented 4 spaces here (nested one level deeper than
153        // the default fixture) — list items should land at 6, not a
154        // hardcoded 4.
155        let manifest = "provides:\n    programs:\n      - asimov-widget-emitter\n";
156        fs::write(&path, manifest).unwrap();
157
158        append_provides_program(&path, "asimov-widget-fetcher").unwrap();
159
160        let updated = fs::read_to_string(&path).unwrap();
161        assert_eq!(
162            updated,
163            "provides:\n    programs:\n      - asimov-widget-emitter\n      - asimov-widget-fetcher\n"
164        );
165    }
166
167    #[test]
168    fn preserves_crlf_line_endings() {
169        let dir = tempdir().unwrap();
170        let path = dir.path().join("module.yaml");
171        let manifest = MANIFEST.replace('\n', "\r\n");
172        fs::write(&path, &manifest).unwrap();
173
174        append_provides_program(&path, "asimov-widget-fetcher").unwrap();
175
176        let updated = fs::read_to_string(&path).unwrap();
177        let expected = manifest.replace(
178            "    - asimov-widget-emitter\r\n",
179            "    - asimov-widget-emitter\r\n    - asimov-widget-fetcher\r\n",
180        );
181        assert_eq!(updated, expected);
182    }
183
184    #[test]
185    fn errors_on_unexpected_shape() {
186        let dir = tempdir().unwrap();
187        let path = dir.path().join("module.yaml");
188        fs::write(&path, "name: widget\n").unwrap();
189
190        let err = append_provides_program(&path, "asimov-widget-emitter").unwrap_err();
191        assert!(matches!(err, ManifestEditError::UnexpectedShape(_)));
192    }
193}