Skip to main content

kick_rs_cli/
register.rs

1//! Conservative text-level edits to wire generated code into the
2//! existing builder chains.
3//!
4//! Why not syn? Re-emitting Rust through `syn` + `prettyplease` loses
5//! formatting, comments, and blank-line layout — adopters then see a
6//! surprise diff every time they generate something. So this module
7//! takes a narrower approach: find a known anchor substring, insert
8//! one line, leave everything else alone.
9//!
10//! The patterns we match are the exact ones the `new` scaffold emits
11//! plus what the users-api example demonstrates. Anything more
12//! creative (extracted-helper bootstrap, custom builder wrapper) is
13//! out of scope — the callers fall back to printing the manual hint.
14
15/// Result of one auto-register attempt. The wrapping caller turns
16/// these into print lines.
17#[derive(Debug, PartialEq, Eq)]
18pub enum RegisterOutcome {
19    /// Edit applied and written.
20    Inserted,
21    /// Target file already contains the registration — nothing to do.
22    AlreadyRegistered,
23    /// No suitable anchor found; caller falls back to printing a hint.
24    AnchorNotFound,
25    /// Target file doesn't exist (e.g. no `src/main.rs`).
26    TargetMissing,
27    /// The user opted out via `--no-register`; we didn't attempt the
28    /// edit at all. Kept distinct from `AnchorNotFound` so the CLI
29    /// can phrase the message correctly.
30    Skipped,
31}
32
33/// Insert `new_call` (a builder method like `.module(modules::posts::define())`)
34/// into `contents` at the right indentation, anchored to the *last*
35/// line containing `anchor_substring`.
36///
37/// Indentation rule:
38/// - If the anchor line (trimmed) starts with `.`, it's itself a chain
39///   element → the new line uses the *same* leading whitespace.
40/// - Otherwise the anchor is the chain opener (`bootstrap()`,
41///   `define_module(...)`) → the new line uses the opener's
42///   whitespace + 4 spaces.
43///
44/// Returns true if an anchor was found and `contents` mutated.
45pub fn insert_chain_call_after_anchor(
46    contents: &mut String,
47    anchor_substring: &str,
48    new_call: &str,
49) -> bool {
50    let lines: Vec<&str> = contents.lines().collect();
51    let pos = lines
52        .iter()
53        .enumerate()
54        .rev()
55        .find(|(_, l)| l.contains(anchor_substring))
56        .map(|(i, _)| i);
57    let Some(i) = pos else {
58        return false;
59    };
60
61    let anchor = lines[i];
62    let anchor_ws: String = anchor.chars().take_while(|c| c.is_whitespace()).collect();
63    let trimmed = anchor.trim_start();
64    let indent = if trimmed.starts_with('.') {
65        anchor_ws
66    } else {
67        format!("{anchor_ws}    ")
68    };
69    let new_line = format!("{indent}{new_call}");
70
71    let mut new_lines: Vec<String> = lines.iter().map(|s| (*s).to_string()).collect();
72    new_lines.insert(i + 1, new_line);
73    *contents = new_lines.join("\n");
74    if !contents.ends_with('\n') {
75        contents.push('\n');
76    }
77    true
78}
79
80/// Insert a `use ...;` line just after the *last* existing `use ...;`
81/// line at the top of the file. Returns true if inserted, false if no
82/// `use` lines were found.
83pub fn insert_use_after_last_use(contents: &mut String, new_use: &str) -> bool {
84    let lines: Vec<&str> = contents.lines().collect();
85    let pos = lines
86        .iter()
87        .enumerate()
88        .rev()
89        .find(|(_, l)| l.trim_start().starts_with("use "))
90        .map(|(i, _)| i);
91    let Some(i) = pos else {
92        return false;
93    };
94
95    let mut new_lines: Vec<String> = lines.iter().map(|s| (*s).to_string()).collect();
96    new_lines.insert(i + 1, new_use.to_string());
97    *contents = new_lines.join("\n");
98    if !contents.ends_with('\n') {
99        contents.push('\n');
100    }
101    true
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn chain_insert_after_opener_indents_plus_four() {
110        let mut src = "bootstrap()\n    .listen(addr)\n    .await\n".to_string();
111        let inserted =
112            insert_chain_call_after_anchor(&mut src, "bootstrap()", ".module(foo::define())");
113        assert!(inserted);
114        // Opener has 0 ws; new chain element should have 4 ws.
115        assert!(
116            src.contains("\n    .module(foo::define())\n    .listen"),
117            "got: {src}"
118        );
119    }
120
121    #[test]
122    fn chain_insert_after_chain_element_keeps_same_indent() {
123        let mut src =
124            "bootstrap()\n    .module(a::define())\n    .listen(addr)\n    .await\n".to_string();
125        let inserted = insert_chain_call_after_anchor(&mut src, ".module(", ".module(b::define())");
126        assert!(inserted);
127        // Anchor `.module(a::define())` is itself a chain element at 4 ws;
128        // new line should also be 4 ws and land *after* the last `.module`.
129        assert!(
130            src.contains("    .module(a::define())\n    .module(b::define())\n    .listen"),
131            "got: {src}"
132        );
133    }
134
135    #[test]
136    fn chain_insert_returns_false_when_anchor_missing() {
137        let mut src = "fn main() {}\n".to_string();
138        let inserted = insert_chain_call_after_anchor(&mut src, "bootstrap()", ".module(x)");
139        assert!(!inserted);
140        assert_eq!(src, "fn main() {}\n");
141    }
142
143    #[test]
144    fn chain_insert_picks_last_anchor_when_multiple() {
145        let mut src =
146            "bootstrap()\n    .module(a)\n    .module(b)\n    .listen(addr)\n".to_string();
147        insert_chain_call_after_anchor(&mut src, ".module(", ".module(c)");
148        // Inserted after the LAST `.module(`, which is `.module(b)`.
149        assert!(
150            src.contains("    .module(b)\n    .module(c)\n    .listen"),
151            "got: {src}"
152        );
153    }
154
155    #[test]
156    fn use_insert_after_last_use_line() {
157        let mut src = "use std::sync::Arc;\nuse kick_rs::*;\n\nfn main() {}\n".to_string();
158        let inserted = insert_use_after_last_use(&mut src, "use foo::Foo;");
159        assert!(inserted);
160        // Lands after the last `use` line, before the blank line.
161        assert!(
162            src.contains("use kick_rs::*;\nuse foo::Foo;\n\nfn main()"),
163            "got: {src}"
164        );
165    }
166
167    #[test]
168    fn use_insert_returns_false_when_no_use_lines() {
169        let mut src = "fn main() {}\n".to_string();
170        let inserted = insert_use_after_last_use(&mut src, "use foo::Foo;");
171        assert!(!inserted);
172        assert_eq!(src, "fn main() {}\n");
173    }
174}