1use std::fs;
27use std::io;
28use std::path::{Path, PathBuf};
29
30pub use umbral_casing::{pascal_case_from_ident, to_snake_case};
31
32#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Target {
36 Root,
38 Plugin(String),
40}
41
42impl Target {
43 pub fn parse(s: &str) -> Self {
46 if s.eq_ignore_ascii_case("root") {
47 Self::Root
48 } else {
49 Self::Plugin(s.to_string())
50 }
51 }
52}
53
54#[derive(Debug, Clone)]
57pub struct ResolvedTarget {
58 pub crate_root: PathBuf,
60 pub owner_file: PathBuf,
64 pub is_root: bool,
67}
68
69impl ResolvedTarget {
70 pub fn module_decl(&self, module: &str) -> String {
73 if self.is_root {
74 format!("mod {module};")
75 } else {
76 format!("pub mod {module};")
77 }
78 }
79}
80
81#[derive(Debug)]
83pub enum CodegenError {
84 InvalidName(String),
86 AlreadyExists(PathBuf),
89 NoSuchPlugin {
92 asked: String,
93 available: Vec<String>,
94 },
95 NotAProject(PathBuf),
97 Io(io::Error),
99}
100
101impl std::fmt::Display for CodegenError {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 match self {
104 Self::InvalidName(s) if RUST_KEYWORDS.contains(&s.replace('-', "_").as_str()) => {
105 write!(
106 f,
107 "`{s}` is a Rust keyword, so it cannot be a module name — the generated \
108 `mod {s};` would not parse. Pick another name."
109 )
110 }
111 Self::InvalidName(s) => write!(
112 f,
113 "invalid name `{s}`: must be ASCII alphanumeric, underscore or hyphen, \
114 and must not start with a digit"
115 ),
116 Self::AlreadyExists(p) => write!(
117 f,
118 "`{}` already exists — pick another name, or delete it first. \
119 Nothing was written.",
120 p.display()
121 ),
122 Self::NoSuchPlugin { asked, available } => {
123 if available.is_empty() {
124 write!(
125 f,
126 "no plugin named `{asked}` — this project has no plugins yet. \
127 Create one with `umbral startapp <name>`, or use `--in root`."
128 )
129 } else {
130 write!(
131 f,
132 "no plugin named `{asked}`. Available: root, {}.",
133 available.join(", ")
134 )
135 }
136 }
137 Self::NotAProject(p) => write!(
138 f,
139 "`{}` doesn't look like an umbral project — no `src/main.rs`.",
140 p.display()
141 ),
142 Self::Io(e) => write!(f, "{e}"),
143 }
144 }
145}
146
147impl std::error::Error for CodegenError {}
148
149impl From<io::Error> for CodegenError {
150 fn from(e: io::Error) -> Self {
151 Self::Io(e)
152 }
153}
154
155#[derive(Debug, Clone, Default)]
157pub struct Scaffolded {
158 pub root: PathBuf,
160 pub files: Vec<PathBuf>,
162 pub next_steps: Vec<String>,
165}
166
167const RUST_KEYWORDS: &[&str] = &[
171 "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
172 "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
173 "ref", "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
174 "where", "while", "abstract", "become", "box", "do", "final", "macro", "override", "priv",
175 "try", "typeof", "unsized", "virtual", "yield",
176];
177
178pub fn validate_ident(name: &str) -> Result<(), CodegenError> {
185 if name.is_empty() {
186 return Err(CodegenError::InvalidName(String::new()));
187 }
188 if name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
189 return Err(CodegenError::InvalidName(name.to_string()));
190 }
191 if !name
192 .chars()
193 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
194 {
195 return Err(CodegenError::InvalidName(name.to_string()));
196 }
197 if RUST_KEYWORDS.contains(&name.replace('-', "_").as_str()) {
198 return Err(CodegenError::InvalidName(name.to_string()));
199 }
200 Ok(())
201}
202
203pub fn discover_plugins(project_root: &Path) -> Vec<String> {
209 let mut names = Vec::new();
210 let Ok(entries) = fs::read_dir(project_root.join("plugins")) else {
211 return names;
212 };
213 for entry in entries.flatten() {
214 if !entry.path().join("Cargo.toml").is_file() {
215 continue;
216 }
217 if let Some(name) = entry.file_name().to_str() {
218 names.push(name.to_string());
219 }
220 }
221 names.sort();
222 names
223}
224
225pub fn resolve_target(
227 project_root: &Path,
228 target: &Target,
229) -> Result<ResolvedTarget, CodegenError> {
230 match target {
231 Target::Root => {
232 let owner_file = project_root.join("src/main.rs");
233 if !owner_file.is_file() {
234 return Err(CodegenError::NotAProject(project_root.to_path_buf()));
235 }
236 Ok(ResolvedTarget {
237 crate_root: project_root.to_path_buf(),
238 owner_file,
239 is_root: true,
240 })
241 }
242 Target::Plugin(name) => {
243 validate_ident(name)?;
254 let crate_root = project_root.join("plugins").join(name);
255 let owner_file = crate_root.join("src/lib.rs");
256 if !owner_file.is_file() {
257 return Err(CodegenError::NoSuchPlugin {
258 asked: name.clone(),
259 available: discover_plugins(project_root),
260 });
261 }
262 Ok(ResolvedTarget {
263 crate_root,
264 owner_file,
265 is_root: false,
266 })
267 }
268 }
269}
270
271pub fn write_new_file(
274 crate_root: &Path,
275 rel_path: &str,
276 contents: &str,
277 files: &mut Vec<PathBuf>,
278) -> Result<(), CodegenError> {
279 let full = crate_root.join(rel_path);
280 if full.exists() {
281 return Err(CodegenError::AlreadyExists(full));
282 }
283 if let Some(parent) = full.parent() {
284 fs::create_dir_all(parent)?;
285 }
286 fs::write(&full, contents)?;
287 files.push(PathBuf::from(rel_path));
288 Ok(())
289}
290
291pub fn declare_module(text: &str, decl: &str) -> Option<String> {
300 if text.lines().any(|l| l.trim() == decl) {
301 return None;
302 }
303 let idx = text
304 .lines()
305 .position(|l| (l.starts_with("mod ") || l.starts_with("pub mod ")) && l.ends_with(';'))?;
306 Some(insert_line_before(text, idx, decl))
307}
308
309pub fn insert_before_marker_once(text: &str, marker: &str, line: &str) -> Option<String> {
320 if text.lines().any(|l| l.trim() == line.trim()) {
321 return Some(text.to_string());
322 }
323 insert_before_marker(text, marker, line)
324}
325
326pub fn insert_before_marker(text: &str, marker: &str, line: &str) -> Option<String> {
333 let idx = text.lines().position(|l| l.trim() == marker)?;
334 Some(insert_line_before(text, idx, line))
335}
336
337pub fn ensure_dependency(cargo_toml: &Path, name: &str, spec: &str) -> Result<bool, CodegenError> {
353 let text = fs::read_to_string(cargo_toml)?;
354
355 let key = format!("{name} =");
366 let table_header = format!("[dependencies.{name}]");
367 let mut in_dependencies = false;
368 let mut deps_header_idx: Option<usize> = None;
369
370 for (idx, line) in text.lines().enumerate() {
371 let trimmed = line.trim();
372 if trimmed.starts_with('[') {
373 if trimmed == table_header {
375 return Ok(false);
376 }
377 in_dependencies = trimmed == "[dependencies]";
378 if in_dependencies {
379 deps_header_idx = Some(idx);
380 }
381 continue;
382 }
383 if in_dependencies && trimmed.starts_with(&key) {
384 return Ok(false);
385 }
386 }
387
388 let Some(idx) = deps_header_idx else {
389 return Err(CodegenError::Io(io::Error::new(
390 io::ErrorKind::InvalidData,
391 format!("`{}` has no [dependencies] section", cargo_toml.display()),
392 )));
393 };
394 let out = insert_line_after(&text, idx, &format!("{name} = {spec}"));
395 fs::write(cargo_toml, out)?;
396 Ok(true)
397}
398
399struct LineStyle {
407 ending: &'static str,
408 trailing_newline: bool,
409}
410
411impl LineStyle {
412 fn of(text: &str) -> Self {
415 let ending = match text.find('\n') {
416 Some(i) if i > 0 && text.as_bytes()[i - 1] == b'\r' => "\r\n",
417 _ => "\n",
418 };
419 Self {
420 ending,
421 trailing_newline: text.is_empty() || text.ends_with('\n'),
422 }
423 }
424
425 fn join(&self, lines: &[&str]) -> String {
427 let mut out = String::new();
428 for (i, l) in lines.iter().enumerate() {
429 out.push_str(l);
430 let last = i + 1 == lines.len();
431 if !last || self.trailing_newline {
432 out.push_str(self.ending);
433 }
434 }
435 out
436 }
437}
438
439pub fn insert_line_before(text: &str, idx: usize, line: &str) -> String {
450 let style = LineStyle::of(text);
451 let mut lines: Vec<&str> = text.lines().collect();
452 let idx = idx.min(lines.len());
453 for (offset, inserted) in line.lines().enumerate() {
454 lines.insert(idx + offset, inserted);
455 }
456 style.join(&lines)
457}
458
459pub fn insert_line_after(text: &str, idx: usize, line: &str) -> String {
461 insert_line_before(text, idx + 1, line)
462}
463
464pub mod prompt {
471 use std::io::{self, BufRead, IsTerminal, Write};
472 use std::path::Path;
473
474 use super::{Target, discover_plugins};
475
476 pub fn is_interactive() -> bool {
479 io::stdin().is_terminal()
480 }
481
482 pub fn ask(question: &str) -> io::Result<String> {
485 print!("{question}");
486 io::stdout().flush()?;
487 let mut line = String::new();
488 if io::stdin().lock().read_line(&mut line)? == 0 {
489 return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "cancelled"));
490 }
491 Ok(line.trim().to_string())
492 }
493
494 pub fn ask_required(question: &str) -> io::Result<String> {
496 loop {
497 let answer = ask(question)?;
498 if !answer.is_empty() {
499 return Ok(answer);
500 }
501 }
502 }
503
504 pub fn ask_target(project_root: &Path) -> io::Result<Target> {
508 let plugins = discover_plugins(project_root);
509
510 println!();
511 println!("Where should it live?");
512 println!(" 1. root — this project's own crate");
513 for (i, p) in plugins.iter().enumerate() {
514 println!(" {}. {p} — the `{p}` plugin (travels with it)", i + 2);
515 }
516 if plugins.is_empty() {
517 println!(" (no plugins yet — `umbral startapp <name>` creates one)");
518 }
519 println!();
520
521 loop {
522 let answer = ask("Choose [1]: ")?;
523 if answer.is_empty() {
524 return Ok(Target::Root);
525 }
526 if let Ok(n) = answer.parse::<usize>() {
527 if n == 1 {
528 return Ok(Target::Root);
529 }
530 if let Some(p) = plugins.get(n - 2) {
531 return Ok(Target::Plugin(p.clone()));
532 }
533 println!(" no such choice: {n}");
534 continue;
535 }
536 if answer.eq_ignore_ascii_case("root") || plugins.iter().any(|p| p == &answer) {
537 return Ok(Target::parse(&answer));
538 }
539 println!(" no such target: `{answer}`");
540 }
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 #[test]
549 fn ensure_dependency_adds_once_and_never_twice() {
550 let tmp = tempfile::tempdir().expect("tempdir");
551 let manifest = tmp.path().join("Cargo.toml");
552 fs::write(
553 &manifest,
554 "[package]\nname = \"blog\"\n\n[dependencies]\numbral = \"1\"\n",
555 )
556 .unwrap();
557
558 assert!(ensure_dependency(&manifest, "umbral-rest", "\"0.0.9\"").unwrap());
559 let text = fs::read_to_string(&manifest).unwrap();
560 assert!(text.contains("umbral-rest = \"0.0.9\""), "{text}");
561 assert!(text.contains("umbral = \"1\""), "{text}");
563
564 assert!(!ensure_dependency(&manifest, "umbral-rest", "\"0.0.9\"").unwrap());
566 assert_eq!(
567 fs::read_to_string(&manifest)
568 .unwrap()
569 .matches("umbral-rest =")
570 .count(),
571 1
572 );
573 }
574
575 #[test]
576 fn ensure_dependency_refuses_a_manifest_with_no_dependencies_section() {
577 let tmp = tempfile::tempdir().expect("tempdir");
578 let manifest = tmp.path().join("Cargo.toml");
579 fs::write(&manifest, "[package]\nname = \"blog\"\n").unwrap();
580 assert!(ensure_dependency(&manifest, "umbral-rest", "\"0.0.9\"").is_err());
581 }
582
583 #[test]
584 fn validate_ident_matches_rust_identifier_rules() {
585 assert!(validate_ident("is_owner").is_ok());
586 assert!(validate_ident("IsOwner").is_ok());
587 assert!(validate_ident("cursor-pagination").is_ok());
588 assert!(validate_ident("").is_err());
589 assert!(validate_ident("2fast").is_err());
590 assert!(validate_ident("is owner").is_err());
591 }
592
593 #[test]
597 fn casing_round_trips_from_either_input_form() {
598 for input in ["IsOwner", "is_owner", "is-owner"] {
599 let pascal = pascal_case_from_ident(input);
600 assert_eq!(pascal, "IsOwner", "from {input}");
601 assert_eq!(to_snake_case(&pascal), "is_owner", "from {input}");
602 }
603 }
604
605 #[test]
606 fn declare_module_inserts_before_the_first_existing_decl() {
607 let text = "//! doc\n\nmod seed;\nmod views;\n\nuse foo;\n";
608 let out = declare_module(text, "mod commands;").expect("should insert");
609 assert!(
610 out.contains("mod commands;\nmod seed;\nmod views;"),
611 "{out}"
612 );
613 }
614
615 #[test]
616 fn declare_module_is_idempotent() {
617 let text = "mod commands;\nmod seed;\n";
618 assert!(
619 declare_module(text, "mod commands;").is_none(),
620 "a declaration already present must not be added twice"
621 );
622 }
623
624 #[test]
625 fn declare_module_declines_when_there_is_no_module_list() {
626 let text = "//! just docs\n\nuse foo;\n";
627 assert!(declare_module(text, "mod commands;").is_none());
628 }
629
630 #[test]
631 fn insert_before_marker_places_the_line_above_the_marker() {
632 let text = "pub mod a;\n// MARK\n";
633 let out = insert_before_marker(text, "// MARK", "pub mod b;").expect("marker present");
634 assert_eq!(out, "pub mod a;\npub mod b;\n// MARK\n");
635 }
636
637 #[test]
638 fn insert_before_marker_declines_when_the_marker_is_gone() {
639 let text = "pub mod a;\n";
640 assert!(
641 insert_before_marker(text, "// MARK", "pub mod b;").is_none(),
642 "without its marker a generator must decline, not guess"
643 );
644 }
645
646 #[test]
647 fn write_new_file_never_overwrites() {
648 let tmp = tempfile::tempdir().expect("tempdir");
649 let mut files = Vec::new();
650 write_new_file(tmp.path(), "src/x.rs", "one", &mut files).expect("first write");
651 let err = write_new_file(tmp.path(), "src/x.rs", "two", &mut files)
652 .expect_err("second write must refuse");
653 assert!(matches!(err, CodegenError::AlreadyExists(_)));
654 assert_eq!(
655 fs::read_to_string(tmp.path().join("src/x.rs")).unwrap(),
656 "one",
657 "the existing file was clobbered"
658 );
659 }
660
661 #[test]
666 fn resolve_target_refuses_a_plugin_name_that_escapes_the_project() {
667 let tmp = tempfile::tempdir().expect("tempdir");
668 let root = tmp.path().join("project");
669 fs::create_dir_all(root.join("src")).unwrap();
670 fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
671
672 let outside = tmp.path().join("other");
675 fs::create_dir_all(outside.join("src")).unwrap();
676 fs::write(outside.join("Cargo.toml"), "[package]\n").unwrap();
677 fs::write(outside.join("src/lib.rs"), "// someone else's code\n").unwrap();
678
679 for escape in [
680 outside.display().to_string(), "../other".to_string(), "..".to_string(),
683 "foo/bar".to_string(),
684 "foo\\bar".to_string(),
685 ] {
686 let err = resolve_target(&root, &Target::Plugin(escape.clone()))
687 .expect_err(&format!("`--in {escape}` must not resolve"));
688 assert!(
689 matches!(err, CodegenError::InvalidName(_)),
690 "`--in {escape}` gave {err:?}, expected InvalidName"
691 );
692 }
693
694 assert_eq!(
696 fs::read_to_string(outside.join("src/lib.rs")).unwrap(),
697 "// someone else's code\n"
698 );
699 }
700
701 #[test]
704 fn validate_ident_rejects_rust_keywords() {
705 for kw in ["move", "type", "match", "struct", "self", "impl"] {
706 assert!(
707 matches!(validate_ident(kw), Err(CodegenError::InvalidName(_))),
708 "`{kw}` is a Rust keyword and cannot be a module name"
709 );
710 }
711 assert!(validate_ident("move_rows").is_ok());
713 assert!(validate_ident("typegen").is_ok());
714 }
715
716 #[test]
720 fn ensure_dependency_is_section_aware() {
721 let tmp = tempfile::tempdir().expect("tempdir");
722 let manifest = tmp.path().join("Cargo.toml");
723 fs::write(
724 &manifest,
725 "[package]\nname = \"blog\"\n\n[dependencies]\numbral = \"1\"\n\n\
726 [dev-dependencies]\numbral-rest = \"0.0.9\"\n",
727 )
728 .unwrap();
729
730 assert!(
731 ensure_dependency(&manifest, "umbral-rest", "\"0.0.10\"").unwrap(),
732 "a dev-dependency must not count as a dependency"
733 );
734 let text = fs::read_to_string(&manifest).unwrap();
735 let deps_at = text.find("[dependencies]").unwrap();
737 let dev_at = text.find("[dev-dependencies]").unwrap();
738 let added_at = text.find("umbral-rest = \"0.0.10\"").unwrap();
739 assert!(
740 deps_at < added_at && added_at < dev_at,
741 "the dep landed outside [dependencies]:\n{text}"
742 );
743 }
744
745 #[test]
748 fn ensure_dependency_recognises_the_table_form() {
749 let tmp = tempfile::tempdir().expect("tempdir");
750 let manifest = tmp.path().join("Cargo.toml");
751 let original = "[package]\nname = \"blog\"\n\n[dependencies]\numbral = \"1\"\n\n\
752 [dependencies.umbral-rest]\nversion = \"0.0.9\"\nfeatures = [\"x\"]\n";
753 fs::write(&manifest, original).unwrap();
754
755 assert!(
756 !ensure_dependency(&manifest, "umbral-rest", "\"0.0.10\"").unwrap(),
757 "the table form must read as already-present"
758 );
759 assert_eq!(
760 fs::read_to_string(&manifest).unwrap(),
761 original,
762 "a duplicate key was written; cargo would refuse this manifest"
763 );
764 }
765
766 #[test]
769 fn edits_preserve_crlf_and_a_missing_trailing_newline() {
770 let crlf = "//! doc\r\n\r\nmod seed;\r\nmod views;\r\n";
771 let out = declare_module(crlf, "mod commands;").expect("insert");
772 assert!(out.contains("mod commands;\r\nmod seed;"), "{out:?}");
773 assert!(
774 !out.contains("mod commands;\nmod seed;"),
775 "LF leaked in: {out:?}"
776 );
777
778 let no_nl = "mod seed;\nmod views;";
780 let out = declare_module(no_nl, "mod commands;").expect("insert");
781 assert!(
782 !out.ends_with('\n'),
783 "a trailing newline was added to a file that had none: {out:?}"
784 );
785
786 let out = insert_before_marker("a\r\n// MARK\r\n", "// MARK", "one\ntwo").expect("marker");
788 assert_eq!(out, "a\r\none\r\ntwo\r\n// MARK\r\n");
789 }
790
791 #[test]
792 fn resolve_target_names_the_owner_file_per_target() {
793 let tmp = tempfile::tempdir().expect("tempdir");
794 let root = tmp.path();
795 fs::create_dir_all(root.join("src")).unwrap();
796 fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
797 fs::create_dir_all(root.join("plugins/blog/src")).unwrap();
798 fs::write(root.join("plugins/blog/Cargo.toml"), "").unwrap();
799 fs::write(root.join("plugins/blog/src/lib.rs"), "").unwrap();
800
801 let r = resolve_target(root, &Target::Root).expect("root");
802 assert!(r.is_root);
803 assert_eq!(r.owner_file, root.join("src/main.rs"));
804 assert_eq!(r.module_decl("commands"), "mod commands;");
805
806 let p = resolve_target(root, &Target::Plugin("blog".into())).expect("plugin");
807 assert!(!p.is_root);
808 assert_eq!(p.owner_file, root.join("plugins/blog/src/lib.rs"));
809 assert_eq!(p.module_decl("commands"), "pub mod commands;");
810
811 match resolve_target(root, &Target::Plugin("blgo".into())) {
812 Err(CodegenError::NoSuchPlugin { asked, available }) => {
813 assert_eq!(asked, "blgo");
814 assert_eq!(available, vec!["blog".to_string()]);
815 }
816 other => panic!("expected NoSuchPlugin, got {other:?}"),
817 }
818 }
819}