Skip to main content

asimov_module_kit/module/
program.rs

1// This is free and unencumbered software released into the public domain.
2
3//! Adding a program to an existing module.
4
5use super::{InvalidProgramName, cargo_toml, manifest_edit, validate_program_name};
6use alloc::{
7    format,
8    string::{String, ToString},
9    vec,
10    vec::Vec,
11};
12use std::{fs, io, path::PathBuf};
13use thiserror::Error;
14use toml_edit::DocumentMut;
15
16#[derive(Clone, Debug)]
17pub struct AddProgramOptions {
18    pub module_dir: PathBuf,
19    pub program_name: String,
20    /// Defaults to a sibling `[[bin]]`'s `required-features`, else `["cli"]`.
21    pub required_features: Vec<String>,
22    /// A local directory containing the template's
23    /// `.template/program.rs.liquid` (e.g. an `asimov-template-module`
24    /// checkout). Never a git URL — callers that only have one (like
25    /// [`super::new_module`]) are responsible for resolving it to a local
26    /// checkout first.
27    pub template_path: PathBuf,
28}
29
30impl AddProgramOptions {
31    pub fn new(
32        module_dir: impl Into<PathBuf>,
33        program_name: impl Into<String>,
34        template_path: impl Into<PathBuf>,
35    ) -> Self {
36        Self {
37            module_dir: module_dir.into(),
38            program_name: program_name.into(),
39            required_features: Vec::new(),
40            template_path: template_path.into(),
41        }
42    }
43}
44
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct AddedProgram {
47    pub program_name: String,
48    /// Derived from `program_name` by stripping the module's own
49    /// `asimov-<module>-` prefix when it's known (from the module's own
50    /// `Cargo.toml`), falling back to the last hyphen segment otherwise.
51    /// Never validated against any fixed list of kinds — any word is
52    /// accepted.
53    pub kind: String,
54    pub source_path: PathBuf,
55    /// `false` if `.asimov/module.yaml` was missing or not in the expected
56    /// shape, in which case it was left untouched.
57    pub manifest_updated: bool,
58}
59
60#[derive(Debug, Error)]
61pub enum AddProgramError {
62    #[error(transparent)]
63    InvalidProgramName(#[from] InvalidProgramName),
64
65    #[error("not a module (no Cargo.toml found): {0}")]
66    NotAModule(PathBuf),
67
68    #[error(
69        "program `{program_name}` doesn't belong to this module; expected the shape `asimov-{module_name}-<kind>`"
70    )]
71    ProgramModuleMismatch {
72        program_name: String,
73        module_name: String,
74    },
75
76    #[error("program `{0}` already declared in Cargo.toml")]
77    ProgramAlreadyExists(String),
78
79    #[error("source path already exists: {0}")]
80    SourcePathExists(PathBuf),
81
82    #[error("failed to parse Cargo.toml: {0}")]
83    CargoToml(#[source] toml_edit::TomlError),
84
85    #[error(transparent)]
86    CargoTomlEdit(#[from] cargo_toml::CargoTomlError),
87
88    #[error(transparent)]
89    ManifestEdit(#[from] manifest_edit::ManifestEditError),
90
91    #[error("failed to render program source: {0}")]
92    Liquid(#[source] liquid::Error),
93
94    #[error(transparent)]
95    Io(#[from] io::Error),
96}
97
98pub fn add_program(options: AddProgramOptions) -> Result<AddedProgram, AddProgramError> {
99    validate_program_name(&options.program_name)?;
100
101    let cargo_toml_path = options.module_dir.join("Cargo.toml");
102    if !cargo_toml_path.exists() {
103        return Err(AddProgramError::NotAModule(options.module_dir.clone()));
104    }
105
106    let mut doc: DocumentMut = fs::read_to_string(&cargo_toml_path)?
107        .parse()
108        .map_err(AddProgramError::CargoToml)?;
109
110    // Best-effort: if the package name follows the usual
111    // `asimov-<module>-module` convention, cross-check that the program
112    // being added actually belongs to this module and not some other one,
113    // and use it below to derive the program's kind precisely (rather than
114    // the naive last-hyphen-segment heuristic, which breaks for
115    // multi-hyphen custom kinds).
116    let module_name: Option<String> = doc
117        .get("package")
118        .and_then(|t| t.get("name"))
119        .and_then(|v| v.as_str())
120        .and_then(|name| name.strip_prefix("asimov-"))
121        .and_then(|name| name.strip_suffix("-module"))
122        .map(String::from);
123    if let Some(module_name) = &module_name {
124        let expected_prefix = format!("asimov-{module_name}-");
125        if !options.program_name.starts_with(&expected_prefix) {
126            return Err(AddProgramError::ProgramModuleMismatch {
127                program_name: options.program_name,
128                module_name: module_name.clone(),
129            });
130        }
131    }
132
133    let mut required_features = options.required_features.clone();
134    if let Some(bins) = doc.get("bin").and_then(|item| item.as_array_of_tables()) {
135        for bin in bins.iter() {
136            if bin.get("name").and_then(|v| v.as_str()) == Some(options.program_name.as_str()) {
137                return Err(AddProgramError::ProgramAlreadyExists(options.program_name));
138            }
139            if required_features.is_empty()
140                && let Some(features) = bin.get("required-features").and_then(|v| v.as_array())
141            {
142                required_features = features
143                    .iter()
144                    .filter_map(|v| v.as_str().map(String::from))
145                    .collect();
146            }
147        }
148    }
149
150    let kind = module_name
151        .as_deref()
152        .and_then(|module_name| {
153            options
154                .program_name
155                .strip_prefix(&format!("asimov-{module_name}-"))
156        })
157        .unwrap_or_else(|| super::program_kind_of(&options.program_name))
158        .to_string();
159    let relative_path = format!("src/{kind}/main.rs");
160    let source_path = options.module_dir.join(&relative_path);
161    if source_path.exists() {
162        return Err(AddProgramError::SourcePathExists(source_path));
163    }
164
165    if required_features.is_empty() {
166        required_features = vec![String::from("cli")];
167    }
168
169    let contents = render_program_source(&options, &kind)?;
170    fs::create_dir_all(
171        source_path
172            .parent()
173            .expect("source_path always has a parent"),
174    )?;
175    fs::write(&source_path, contents)?;
176
177    cargo_toml::insert_bin(
178        &mut doc,
179        &options.program_name,
180        &relative_path,
181        &required_features,
182    )?;
183    fs::write(&cargo_toml_path, doc.to_string())?;
184
185    let manifest_path = options.module_dir.join(".asimov/module.yaml");
186    let manifest_updated = if manifest_path.exists() {
187        match manifest_edit::append_provides_program(&manifest_path, &options.program_name) {
188            Ok(()) => true,
189            Err(manifest_edit::ManifestEditError::UnexpectedShape(_)) => false,
190            Err(err) => return Err(err.into()),
191        }
192    } else {
193        false
194    };
195
196    Ok(AddedProgram {
197        program_name: options.program_name,
198        kind,
199        source_path,
200        manifest_updated,
201    })
202}
203
204fn render_program_source(
205    options: &AddProgramOptions,
206    kind: &str,
207) -> Result<String, AddProgramError> {
208    let liquid_path = options.template_path.join(".template/program.rs.liquid");
209    let text = fs::read_to_string(&liquid_path)?;
210    let parser = liquid::ParserBuilder::with_stdlib()
211        .build()
212        .map_err(AddProgramError::Liquid)?;
213    let template = parser.parse(&text).map_err(AddProgramError::Liquid)?;
214    let globals = liquid::object!({
215        "program_name": options.program_name.clone(),
216        "program_kind": kind.to_string(),
217    });
218    template.render(&globals).map_err(AddProgramError::Liquid)
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use tempfile::tempdir;
225
226    fn fixture_template(dir: &std::path::Path) {
227        fs::create_dir_all(dir.join(".template")).unwrap();
228        fs::write(
229            dir.join(".template/program.rs.liquid"),
230            "// {{ program_name }} ({{ program_kind }})\nfn main() { println!(\"{{ program_name }}\"); }\n",
231        )
232        .unwrap();
233    }
234
235    fn fixture_module(dir: &std::path::Path) {
236        fs::create_dir_all(dir.join("src/emitter")).unwrap();
237        fs::write(
238            dir.join("Cargo.toml"),
239            r#"
240[package]
241name = "asimov-widget-module"
242
243[[bin]]
244name = "asimov-widget-emitter"
245path = "src/emitter/main.rs"
246required-features = ["cli"]
247"#,
248        )
249        .unwrap();
250        fs::write(dir.join("src/emitter/main.rs"), "fn main() {}").unwrap();
251        fs::create_dir_all(dir.join(".asimov")).unwrap();
252        fs::write(
253            dir.join(".asimov/module.yaml"),
254            "provides:\n  programs:\n    - asimov-widget-emitter\n",
255        )
256        .unwrap();
257    }
258
259    #[test]
260    fn adds_a_program_from_local_template() {
261        let template_dir = tempdir().unwrap();
262        fixture_template(template_dir.path());
263
264        let module_dir = tempdir().unwrap();
265        fixture_module(module_dir.path());
266
267        let options = AddProgramOptions::new(
268            module_dir.path(),
269            "asimov-widget-fetcher",
270            template_dir.path(),
271        );
272        let added = add_program(options).unwrap();
273
274        assert_eq!(added.kind, "fetcher");
275        assert!(added.manifest_updated);
276        assert!(added.source_path.ends_with("src/fetcher/main.rs"));
277
278        let source = fs::read_to_string(&added.source_path).unwrap();
279        assert!(source.contains("asimov-widget-fetcher"));
280        assert!(source.contains("(fetcher)"));
281
282        let cargo_toml = fs::read_to_string(module_dir.path().join("Cargo.toml")).unwrap();
283        assert!(cargo_toml.contains("name = \"asimov-widget-fetcher\""));
284        assert!(cargo_toml.contains("path = \"src/fetcher/main.rs\""));
285        // The existing bin's required-features convention was copied:
286        assert!(cargo_toml.matches("required-features = [\"cli\"]").count() == 2);
287
288        let manifest = fs::read_to_string(module_dir.path().join(".asimov/module.yaml")).unwrap();
289        assert!(manifest.contains("asimov-widget-emitter"));
290        assert!(manifest.contains("asimov-widget-fetcher"));
291    }
292
293    #[test]
294    fn rejects_duplicate_program() {
295        let template_dir = tempdir().unwrap();
296        fixture_template(template_dir.path());
297        let module_dir = tempdir().unwrap();
298        fixture_module(module_dir.path());
299
300        let options = AddProgramOptions::new(
301            module_dir.path(),
302            "asimov-widget-emitter",
303            template_dir.path(),
304        );
305        assert!(matches!(
306            add_program(options),
307            Err(AddProgramError::ProgramAlreadyExists(_))
308        ));
309    }
310
311    #[test]
312    fn rejects_existing_source_path() {
313        let template_dir = tempdir().unwrap();
314        fixture_template(template_dir.path());
315        let module_dir = tempdir().unwrap();
316        fixture_module(module_dir.path());
317        fs::create_dir_all(module_dir.path().join("src/fetcher")).unwrap();
318        fs::write(
319            module_dir.path().join("src/fetcher/main.rs"),
320            "fn main() {}",
321        )
322        .unwrap();
323
324        let options = AddProgramOptions::new(
325            module_dir.path(),
326            "asimov-widget-fetcher",
327            template_dir.path(),
328        );
329        assert!(matches!(
330            add_program(options),
331            Err(AddProgramError::SourcePathExists(_))
332        ));
333    }
334
335    #[test]
336    fn rejects_program_name_not_matching_module() {
337        let template_dir = tempdir().unwrap();
338        fixture_template(template_dir.path());
339        let module_dir = tempdir().unwrap();
340        fixture_module(module_dir.path());
341
342        let options = AddProgramOptions::new(
343            module_dir.path(),
344            "asimov-camera-fetcher",
345            template_dir.path(),
346        );
347        assert!(matches!(
348            add_program(options),
349            Err(AddProgramError::ProgramModuleMismatch { .. })
350        ));
351    }
352
353    #[test]
354    fn rejects_not_a_module() {
355        let template_dir = tempdir().unwrap();
356        fixture_template(template_dir.path());
357        let module_dir = tempdir().unwrap();
358
359        let options = AddProgramOptions::new(
360            module_dir.path(),
361            "asimov-widget-fetcher",
362            template_dir.path(),
363        );
364        assert!(matches!(
365            add_program(options),
366            Err(AddProgramError::NotAModule(_))
367        ));
368    }
369
370    #[test]
371    fn accepts_arbitrary_unlisted_kind() {
372        let template_dir = tempdir().unwrap();
373        fixture_template(template_dir.path());
374        let module_dir = tempdir().unwrap();
375        fixture_module(module_dir.path());
376
377        let options = AddProgramOptions::new(
378            module_dir.path(),
379            "asimov-widget-whatever",
380            template_dir.path(),
381        );
382        let added = add_program(options).unwrap();
383        assert_eq!(added.kind, "whatever");
384    }
385
386    #[test]
387    fn skips_manifest_update_when_missing() {
388        let template_dir = tempdir().unwrap();
389        fixture_template(template_dir.path());
390        let module_dir = tempdir().unwrap();
391        fixture_module(module_dir.path());
392        fs::remove_dir_all(module_dir.path().join(".asimov")).unwrap();
393
394        let options = AddProgramOptions::new(
395            module_dir.path(),
396            "asimov-widget-fetcher",
397            template_dir.path(),
398        );
399        let added = add_program(options).unwrap();
400        assert!(!added.manifest_updated);
401        // The program source and Cargo.toml entry are still written:
402        assert!(added.source_path.exists());
403        let cargo_toml = fs::read_to_string(module_dir.path().join("Cargo.toml")).unwrap();
404        assert!(cargo_toml.contains("asimov-widget-fetcher"));
405    }
406
407    #[test]
408    fn propagates_manifest_io_errors_instead_of_swallowing_them() {
409        let template_dir = tempdir().unwrap();
410        fixture_template(template_dir.path());
411        let module_dir = tempdir().unwrap();
412        fixture_module(module_dir.path());
413
414        // Replace the manifest file with a directory so reading it fails
415        // with a real I/O error, not `UnexpectedShape`.
416        fs::remove_file(module_dir.path().join(".asimov/module.yaml")).unwrap();
417        fs::create_dir(module_dir.path().join(".asimov/module.yaml")).unwrap();
418
419        let options = AddProgramOptions::new(
420            module_dir.path(),
421            "asimov-widget-fetcher",
422            template_dir.path(),
423        );
424        assert!(matches!(
425            add_program(options),
426            Err(AddProgramError::ManifestEdit(
427                manifest_edit::ManifestEditError::Io(_)
428            ))
429        ));
430    }
431}