asimov_module_kit/module/
manifest_edit.rs1use 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
27pub 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 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}