Skip to main content

umbral_core/
codegen.rs

1//! Scaffolding primitives — how a command writes code into the user's project.
2//!
3//! `umbral startcommand` needs these. So does any *plugin* that wants to
4//! generate code: `umbral-rest` ships `startpermission` /
5//! `startauthentication` / `startpagination` / `startthrottle`, and a
6//! third-party plugin can ship its own generator with nothing more than the
7//! facade. That's the whole point of putting this in core rather than in
8//! `umbral-cli`: a plugin can't depend on the CLI (dependencies point
9//! *inward*), so if the file surgery lived there, every plugin generator
10//! would hand-roll its own — and hand-rolled `mod` insertion is how a
11//! generator eats somebody's `main.rs`.
12//!
13//! What's here is deliberately small and boring:
14//!
15//! - [`Target`] / [`resolve_target`] — "root or which plugin?", the question
16//!   every generator asks, answered against what's actually on disk.
17//! - [`write_new_file`] — write, never overwrite.
18//! - [`declare_module`] / [`insert_before_marker`] — the two edits a
19//!   generator makes to a file it doesn't own.
20//!
21//! Every function that edits an existing file returns `Option`/`Result` and
22//! **declines** when the file isn't the shape it expected. A generator that
23//! guesses at a file it doesn't recognise is a generator that corrupts it;
24//! the caller reports the lines to add by hand instead.
25
26use std::fs;
27use std::io;
28use std::path::{Path, PathBuf};
29
30pub use umbral_casing::{pascal_case_from_ident, to_snake_case};
31
32/// Where generated code lands: the project's own crate, or one of its
33/// plugins.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Target {
36    /// The project itself — `src/`, owned by `main.rs`.
37    Root,
38    /// A plugin crate — `plugins/<name>/src/`, owned by its `lib.rs`.
39    Plugin(String),
40}
41
42impl Target {
43    /// Parse the `--in` argument. `root` (any case) is the project; anything
44    /// else names a plugin.
45    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/// A resolved [`Target`]: the crate to write into, and the file that owns
55/// its module tree.
56#[derive(Debug, Clone)]
57pub struct ResolvedTarget {
58    /// The crate root — the directory holding `src/` and `Cargo.toml`.
59    pub crate_root: PathBuf,
60    /// The file that declares the crate's modules: `src/main.rs` for the
61    /// project, `src/lib.rs` for a plugin. A generator adds its `mod foo;`
62    /// here.
63    pub owner_file: PathBuf,
64    /// True for [`Target::Root`]. Generators use it to pick between `mod x;`
65    /// (a binary's private module) and `pub mod x;` (a library's public one).
66    pub is_root: bool,
67}
68
69impl ResolvedTarget {
70    /// `mod x;` in a binary, `pub mod x;` in a plugin library — the module
71    /// declaration appropriate to this target.
72    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/// Errors a generator can hit before it writes anything.
82#[derive(Debug)]
83pub enum CodegenError {
84    /// Not usable as a Rust identifier.
85    InvalidName(String),
86    /// The file is already there. Generators never overwrite: the file may be
87    /// hours of someone's work with the same name.
88    AlreadyExists(PathBuf),
89    /// `--in <plugin>` named something that isn't under `plugins/`. Carries
90    /// the names that ARE, so the message can list the real choices.
91    NoSuchPlugin {
92        asked: String,
93        available: Vec<String>,
94    },
95    /// No `src/main.rs` here — this isn't a project root.
96    NotAProject(PathBuf),
97    /// I/O failure.
98    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/// What a generator wrote, for the report it prints.
156#[derive(Debug, Clone, Default)]
157pub struct Scaffolded {
158    /// The crate the files landed in.
159    pub root: PathBuf,
160    /// Files written, relative to `root`.
161    pub files: Vec<PathBuf>,
162    /// What the user still has to do — the registration line a generator
163    /// can't place for them, or a file it declined to edit.
164    pub next_steps: Vec<String>,
165}
166
167/// Rust keywords that cannot be a module name. A command called `move` would
168/// generate `pub mod move;`, which does not parse — and the user would be
169/// staring at a syntax error in a file they did not write.
170const 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
178/// Validate a name as a Rust identifier stem: ASCII alphanumeric, `_`, `-`,
179/// not starting with a digit, not empty, not a Rust keyword.
180///
181/// This is also the **only** thing standing between a `--in` argument and the
182/// filesystem, so it has to reject anything that could be a path: `/`, `\`,
183/// `.` and `..` all fail the alphanumeric test. See [`resolve_target`].
184pub 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
203/// The plugins available in this project: every `plugins/<name>/` that holds
204/// a `Cargo.toml`.
205///
206/// Reads the disk, not `main.rs`. A plugin you scaffolded but haven't
207/// registered yet is still a legitimate place to put code.
208pub 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
225/// Resolve a [`Target`] against the project on disk.
226pub 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 BEFORE joining. `Path::join` with an absolute component
244            // throws the base away — `join("plugins").join("/home/me/other")`
245            // is `/home/me/other` — so an unvalidated `--in` would let a
246            // generator write into, and rewrite the lib.rs and Cargo.toml of,
247            // a crate outside this project entirely. `..` traverses out the
248            // same way. `validate_ident` rejects `/`, `\`, `.` and `..`
249            // because none of them are alphanumeric, which is exactly the
250            // property we need here: a plugin name is an identifier, never a
251            // path, and the one place that was assumed rather than checked is
252            // the one place it mattered.
253            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
271/// Write a file that must not already exist, recording it in `files`
272/// (relative to `crate_root`) for the report.
273pub 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
291/// Add a module declaration (`mod foo;` / `pub mod foo;`) to a file's module
292/// list, placed before the first existing declaration so the list stays
293/// readable at the top of the file.
294///
295/// Returns `None` when the declaration is already there (idempotent — the
296/// second generator run is a no-op) or when the file declares no modules at
297/// all and there's no obvious place to put one. The caller reports it as a
298/// manual step rather than guessing.
299pub 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
309/// [`insert_before_marker`], but a no-op when `line` is already in the file.
310///
311/// The idempotency is the whole game for a registry a generator appends to: a
312/// blind insert turns a re-run into a duplicate `pub mod x;`, and a duplicate
313/// module declaration does not compile. Both `startcommand` and the REST class
314/// generators independently grew their own copy of this guard — which is the
315/// signal that it belonged here.
316///
317/// `Some(text)` unchanged when the line is present; `None` only when the marker
318/// itself is gone (the caller then declines and reports).
319pub 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
326/// Insert `line` immediately before the line that equals `marker` (trimmed).
327///
328/// Marker comments are how a generator finds its insertion point without
329/// parsing Rust. Returns `None` when the marker is gone — the user
330/// restructured the file, and a generator that "helpfully" rewrites a file it
331/// no longer recognises is worse than one that asks.
332pub 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
337/// Ensure `<name> = <spec>` is listed under `[dependencies]` in a
338/// `Cargo.toml`.
339///
340/// `Ok(true)` = added, `Ok(false)` = already there (idempotent),
341/// `Err(_)` = the file has no `[dependencies]` section to add it to, and the
342/// caller should report it rather than invent one.
343///
344/// The generator needs this because code scaffolded into a *plugin* crate
345/// usually needs a dependency the plugin doesn't have yet — a REST permission
346/// class in `plugins/blog/` doesn't compile until `plugins/blog/Cargo.toml`
347/// depends on `umbral-rest`. Writing the file and leaving the crate unable to
348/// build it is a generator that produced a broken project.
349///
350/// String surgery, not a TOML rewrite: comments, ordering and formatting of
351/// the existing manifest all survive.
352pub fn ensure_dependency(cargo_toml: &Path, name: &str, spec: &str) -> Result<bool, CodegenError> {
353    let text = fs::read_to_string(cargo_toml)?;
354
355    // The presence check has to be SECTION-AWARE, and it has to know both
356    // spellings of a dependency. Getting either wrong breaks the user's build:
357    //
358    //   - Section-blind: a crate listed under `[dev-dependencies]` (for its
359    //     tests) would read as "already a dependency", we'd add nothing, and
360    //     the code we just generated would fail with `unresolved import` — the
361    //     exact failure this function exists to prevent.
362    //   - Form-blind: the table form (`[dependencies.umbral-rest]`) doesn't
363    //     match `umbral-rest =`, so we'd append a second `umbral-rest = "..."`
364    //     key and cargo would refuse to parse the manifest at all.
365    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            // A `[dependencies.<name>]` header IS the dependency, in table form.
374            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
399/// How a file terminates its lines, and whether it ends with one.
400///
401/// A generator edits a file it does not own, so it must give back what it was
402/// given: rebuilding a CRLF file with `\n` rewrites every line in the diff, and
403/// appending a newline to a file that had none is a change the user never made.
404/// Neither breaks the build — which is exactly why they'd survive review and
405/// show up as noise in someone's next `git diff`.
406struct LineStyle {
407    ending: &'static str,
408    trailing_newline: bool,
409}
410
411impl LineStyle {
412    /// Sniff the style of an existing file. CRLF if the first terminator is
413    /// `\r\n` — mixed endings are pathological and we follow the majority-of-one.
414    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    /// Re-emit `lines` in this style.
426    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
439/// Insert `line` before line index `idx`, preserving the file's line endings
440/// and its trailing-newline habit.
441///
442/// `line` may itself be several lines (a generated method body); each is
443/// re-emitted in the target file's style, so a multi-line insert into a CRLF
444/// file doesn't leave bare `\n` behind.
445///
446/// Prefer [`declare_module`] or [`insert_before_marker`] — they find their own
447/// anchor and decline when the file isn't what they expected. Reach for this
448/// only when the caller has located the line itself.
449pub 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
459/// Insert `line` after line index `idx`, preserving the file's line style.
460pub fn insert_line_after(text: &str, idx: usize, line: &str) -> String {
461    insert_line_before(text, idx + 1, line)
462}
463
464/// Terminal prompts for a generator that asks before it writes.
465///
466/// Shared so `umbral startcommand` and a plugin's generator ask the same
467/// questions the same way — and so a plugin author gets the non-obvious part
468/// for free: **a prompt is only legal when stdin is a terminal.** Prompting a
469/// pipe blocks a CI job forever on a question nothing will ever answer.
470pub mod prompt {
471    use std::io::{self, BufRead, IsTerminal, Write};
472    use std::path::Path;
473
474    use super::{Target, discover_plugins};
475
476    /// Whether we may prompt at all. `false` in CI, a pipe, a cron job —
477    /// where the caller must fall back to flags or fail with a clear message.
478    pub fn is_interactive() -> bool {
479        io::stdin().is_terminal()
480    }
481
482    /// Ask, and take a blank answer as an answer (the caller has a default).
483    /// EOF (Ctrl-D) is a cancellation, not a blank.
484    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    /// Ask until the answer isn't blank.
495    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    /// Ask where generated code should live: the project root, or one of the
505    /// plugins found on disk. Accepts the menu number or the name; blank takes
506    /// the default (root).
507    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        // The existing manifest is untouched apart from the new line.
562        assert!(text.contains("umbral = \"1\""), "{text}");
563
564        // Idempotent: a second call adds nothing.
565        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    /// Either input form lands on the same pair: the struct is PascalCase,
594    /// the module snake_case. A user typing `IsOwner` and a user typing
595    /// `is_owner` must get the same file.
596    #[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    /// The `--in` argument reaches `Path::join`, and `join` with an ABSOLUTE
662    /// path throws the base away. Unvalidated, `--in /home/me/other-project`
663    /// would make a generator write into — and rewrite the lib.rs and
664    /// Cargo.toml of — an unrelated crate. `..` traverses out the same way.
665    #[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        // The "other project" a path escape would land in — a real crate, so
673        // the only thing that can save it is the name check.
674        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(), // absolute — join() discards the base
681            "../other".to_string(),        // traversal
682            "..".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        // And the file it would have written is not there.
695        assert_eq!(
696            fs::read_to_string(outside.join("src/lib.rs")).unwrap(),
697            "// someone else's code\n"
698        );
699    }
700
701    /// `pub mod move;` doesn't parse. A generator that emits it hands the user
702    /// a syntax error in a file they didn't write.
703    #[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        // Not keywords, just near them.
712        assert!(validate_ident("move_rows").is_ok());
713        assert!(validate_ident("typegen").is_ok());
714    }
715
716    /// A dep under `[dev-dependencies]` is NOT a dependency of the code we
717    /// just generated. Reading it as one leaves the crate with an unresolved
718    /// import — the exact failure `ensure_dependency` exists to prevent.
719    #[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        // Added under [dependencies], and the dev-dependency is untouched.
736        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    /// The table form IS the dependency. Not recognising it means appending a
746    /// second key — and cargo then refuses to parse the manifest at all.
747    #[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    /// A generator edits files it does not own. Handing back LF for a CRLF file
767    /// rewrites every line in the user's next diff.
768    #[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        // No trailing newline in, none out.
779        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        // A multi-line insert into a CRLF file stays CRLF throughout.
787        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}