Skip to main content

gize_generator/
registry.rs

1//! Idempotent edits to "registry" files — the ones `gize make app` must update in place
2//! rather than create: `src/app/mod.rs` (module + route wiring).
3//!
4//! MVP approach: marker-based insertion. The generated `app/mod.rs` carries two markers
5//! (`gize:modules` and `gize:module-routes`); we insert around them and short-circuit if
6//! the module is already registered, which makes re-runs a no-op. ADR-004 earmarks a
7//! `syn`-based editor as a hardening follow-up; the pure-function boundary here keeps that
8//! swap internal.
9
10use anyhow::{Result, bail};
11
12const MODULES_MARKER: &str = "// gize:modules (do not remove this marker)";
13const ROUTES_MARKER: &str = "// gize:module-routes (do not remove this marker)";
14
15/// The result of a registry edit.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Edit {
18    /// Whether the edit changed anything (false = already registered).
19    pub changed: bool,
20    /// The (possibly unchanged) file content.
21    pub content: String,
22}
23
24/// Register `module` in an `app/mod.rs` source: add `mod <module>;` and merge its routes.
25/// Idempotent — if the module is already present, returns `changed: false`.
26pub fn register_module(source: &str, module: &str) -> Result<Edit> {
27    let mod_decl = format!("mod {module};");
28    if source.contains(&mod_decl) {
29        return Ok(Edit {
30            changed: false,
31            content: source.to_string(),
32        });
33    }
34
35    if !source.contains(MODULES_MARKER) || !source.contains(ROUTES_MARKER) {
36        bail!(
37            "src/app/mod.rs is missing gize markers; cannot register `{module}` automatically. \
38             Re-add the `// gize:modules` and `// gize:module-routes` markers or wire the module by hand."
39        );
40    }
41
42    // Insert the `mod` declaration and the `.merge(...)` call in registration order. The CLI runs
43    // rustfmt over `app/mod.rs` after this edit (ADR-020), which sorts the `mod` declarations and
44    // collapses/wraps the merge chain, so the file lands rustfmt-clean regardless of module count.
45    let with_mod = insert_after_marker(source, MODULES_MARKER, &mod_decl);
46    let merge_call = format!("        .merge({module}::routes())");
47    let content = insert_before_marker(&with_mod, ROUTES_MARKER, &merge_call);
48
49    Ok(Edit {
50        changed: true,
51        content,
52    })
53}
54
55/// Insert `new_line` immediately after the first line containing `marker`.
56fn insert_after_marker(source: &str, marker: &str, new_line: &str) -> String {
57    let mut out: Vec<String> = Vec::new();
58    for line in source.lines() {
59        out.push(line.to_string());
60        if line.contains(marker) {
61            out.push(new_line.to_string());
62        }
63    }
64    finish(out, source)
65}
66
67/// Insert `new_line` immediately before the first line containing `marker`.
68fn insert_before_marker(source: &str, marker: &str, new_line: &str) -> String {
69    let mut out: Vec<String> = Vec::new();
70    for line in source.lines() {
71        if line.contains(marker) {
72            out.push(new_line.to_string());
73        }
74        out.push(line.to_string());
75    }
76    finish(out, source)
77}
78
79/// Re-join lines, preserving a trailing newline if the source had one.
80fn finish(lines: Vec<String>, source: &str) -> String {
81    let mut s = lines.join("\n");
82    if source.ends_with('\n') {
83        s.push('\n');
84    }
85    s
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    const APP_MOD: &str = r#"use axum::Router;
93
94use crate::state::AppState;
95
96// gize:modules (do not remove this marker)
97
98pub fn routes() -> Router<AppState> {
99    Router::new()
100    // gize:module-routes (do not remove this marker)
101}
102"#;
103
104    #[test]
105    fn registers_a_new_module() {
106        let edit = register_module(APP_MOD, "users").unwrap();
107        assert!(edit.changed);
108        assert!(edit.content.contains("mod users;"));
109        assert!(edit.content.contains(".merge(users::routes())"));
110        // markers are preserved for the next registration
111        assert!(edit.content.contains(MODULES_MARKER));
112        assert!(edit.content.contains(ROUTES_MARKER));
113    }
114
115    #[test]
116    fn is_idempotent() {
117        let first = register_module(APP_MOD, "users").unwrap();
118        let second = register_module(&first.content, "users").unwrap();
119        assert!(!second.changed);
120        assert_eq!(first.content, second.content);
121    }
122
123    #[test]
124    fn registers_multiple_modules() {
125        let first = register_module(APP_MOD, "users").unwrap();
126        let second = register_module(&first.content, "products").unwrap();
127        assert!(second.changed);
128        assert!(second.content.contains("mod users;"));
129        assert!(second.content.contains("mod products;"));
130        assert!(second.content.contains(".merge(users::routes())"));
131        assert!(second.content.contains(".merge(products::routes())"));
132    }
133
134    #[test]
135    fn fails_without_markers() {
136        assert!(register_module("fn routes() {}", "users").is_err());
137    }
138}