asimov_module_kit/module/
program.rs1use 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 pub required_features: Vec<String>,
22 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 pub kind: String,
54 pub source_path: PathBuf,
55 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 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 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 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 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}