Skip to main content

kick_rs_cli/
info.rs

1//! `cargo kick info` — print a snapshot of the current kick-rs project.
2//!
3//! Pulls together:
4//! - Package metadata from `Cargo.toml` (name, version)
5//! - Which `kick-rs` version + features the project declares
6//! - Every module on disk (under `src/modules/<name>/mod.rs`), plus
7//!   the services and contributors registered in each module's
8//!   builder chain.
9//!
10//! Module-detail extraction is text-level — we scan for the same
11//! patterns the scaffold + generators emit (`define_module(...)`,
12//! `.prefix(...)`, `.service::<...>()`, `.contribute(...)`). That
13//! intentionally undercovers exotic adopter shapes; cleanly-shaped
14//! projects (generated by `cargo kick g`) report perfectly.
15
16use crate::generate::{find_project_root, GenerateError};
17use std::fs;
18use std::path::{Path, PathBuf};
19use toml_edit::{DocumentMut, Value};
20
21/// Decoded form of the `info` subcommand.
22pub struct InfoArgs {
23    pub project_root: Option<PathBuf>,
24    pub dep_name: String,
25}
26
27/// Top-level snapshot rendered by [`render_info`].
28#[derive(Debug)]
29pub struct ProjectInfo {
30    pub root: PathBuf,
31    pub package_name: String,
32    pub package_version: String,
33    pub kick_rs_version: Option<String>,
34    pub kick_rs_features: Vec<String>,
35    pub modules: Vec<ModuleInfo>,
36}
37
38#[derive(Debug)]
39pub struct ModuleInfo {
40    /// Directory name on disk (e.g. `users`).
41    pub dir_name: String,
42    /// `define_module("X")` literal, when found.
43    pub declared_name: Option<String>,
44    /// `.prefix("/X")` literal, when found.
45    pub prefix: Option<String>,
46    /// Identifiers between `.service::<` and `>()`.
47    pub services: Vec<String>,
48    /// Identifiers between `.contribute(` and `)`.
49    pub contributors: Vec<String>,
50}
51
52#[derive(Debug)]
53pub enum InfoError {
54    Io {
55        path: PathBuf,
56        source: std::io::Error,
57    },
58    Toml {
59        path: PathBuf,
60        source: Box<toml_edit::TomlError>,
61    },
62    ProjectRoot(GenerateError),
63}
64
65impl std::fmt::Display for InfoError {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            Self::Io { path, source } => write!(f, "I/O error at `{}`: {source}", path.display()),
69            Self::Toml { path, source } => {
70                write!(f, "could not parse `{}`: {source}", path.display())
71            }
72            Self::ProjectRoot(e) => write!(f, "{e}"),
73        }
74    }
75}
76
77impl std::error::Error for InfoError {}
78
79/// Collect the snapshot from disk.
80pub fn collect_info(args: &InfoArgs) -> Result<ProjectInfo, InfoError> {
81    let root = match &args.project_root {
82        Some(p) => p.clone(),
83        None => find_project_root(Path::new(".")).map_err(InfoError::ProjectRoot)?,
84    };
85
86    let cargo_toml = root.join("Cargo.toml");
87    let contents = fs::read_to_string(&cargo_toml).map_err(|e| InfoError::Io {
88        path: cargo_toml.clone(),
89        source: e,
90    })?;
91    let doc: DocumentMut = contents.parse().map_err(|e| InfoError::Toml {
92        path: cargo_toml.clone(),
93        source: Box::new(e),
94    })?;
95
96    let pkg = doc
97        .get("package")
98        .and_then(|i| i.as_table_like())
99        .ok_or_else(|| InfoError::Io {
100            path: cargo_toml.clone(),
101            source: std::io::Error::other("Cargo.toml has no [package] table"),
102        })?;
103    let package_name = pkg
104        .get("name")
105        .and_then(|i| i.as_str())
106        .unwrap_or("<unknown>")
107        .to_owned();
108    let package_version = pkg
109        .get("version")
110        .and_then(|i| i.as_str())
111        .unwrap_or("<unknown>")
112        .to_owned();
113
114    let (kick_rs_version, kick_rs_features) = doc
115        .get("dependencies")
116        .and_then(|i| i.as_table_like())
117        .and_then(|t| t.get(&args.dep_name))
118        .map(|item| match item {
119            toml_edit::Item::Value(Value::String(s)) => (Some(s.value().to_owned()), Vec::new()),
120            toml_edit::Item::Value(Value::InlineTable(t)) => {
121                let v = t.get("version").and_then(|x| x.as_str()).map(str::to_owned);
122                let feats = t
123                    .get("features")
124                    .and_then(|x| x.as_array())
125                    .map(|a| {
126                        a.iter()
127                            .filter_map(|x| x.as_str().map(str::to_owned))
128                            .collect()
129                    })
130                    .unwrap_or_default();
131                (v, feats)
132            }
133            _ => (None, Vec::new()),
134        })
135        .unwrap_or((None, Vec::new()));
136
137    let modules = collect_modules(&root.join("src/modules"));
138
139    Ok(ProjectInfo {
140        root,
141        package_name,
142        package_version,
143        kick_rs_version,
144        kick_rs_features,
145        modules,
146    })
147}
148
149fn collect_modules(modules_dir: &Path) -> Vec<ModuleInfo> {
150    let Ok(entries) = fs::read_dir(modules_dir) else {
151        return Vec::new();
152    };
153    let mut out: Vec<ModuleInfo> = entries
154        .filter_map(|e| e.ok())
155        .filter_map(|e| {
156            let path = e.path();
157            if !path.is_dir() {
158                return None;
159            }
160            let dir_name = path.file_name()?.to_str()?.to_owned();
161            // Each module owns a mod.rs. If absent, this isn't a module —
162            // skip it rather than reporting a half-populated entry.
163            let mod_rs = path.join("mod.rs");
164            let body = fs::read_to_string(&mod_rs).ok()?;
165            Some(extract_module_info(dir_name, &body))
166        })
167        .collect();
168    out.sort_by(|a, b| a.dir_name.cmp(&b.dir_name));
169    out
170}
171
172/// Pull the `define_module(...)`, `.prefix(...)`, `.service::<...>()`,
173/// `.contribute(...)` mentions out of a module's `mod.rs`. Text-level
174/// scan, conservative on purpose — adopters with custom builder
175/// wrappers fall back to seeing just the directory name + a
176/// dir_name-only entry, which is still useful.
177pub(crate) fn extract_module_info(dir_name: String, body: &str) -> ModuleInfo {
178    // r#""# is the *empty* string (the content between r#" and "# is
179    // zero chars). For a single `"` close delimiter we use the
180    // ordinary escaped form.
181    let declared_name = scan_between(body, "define_module(\"", "\"");
182    let prefix = scan_between(body, ".prefix(\"", "\"");
183    let services = scan_all_between(body, ".service::<", ">()");
184    let contributors = scan_all_between(body, ".contribute(", ")");
185    ModuleInfo {
186        dir_name,
187        declared_name,
188        prefix,
189        services,
190        contributors,
191    }
192}
193
194/// Find the *first* occurrence of `open`, then everything up to (but
195/// not including) the next `close`. Returns the matched span trimmed
196/// of leading whitespace, or `None` if either delimiter is missing.
197fn scan_between(haystack: &str, open: &str, close: &str) -> Option<String> {
198    let start = haystack.find(open)?;
199    let after_open = &haystack[start + open.len()..];
200    let end = after_open.find(close)?;
201    Some(after_open[..end].trim().to_owned())
202}
203
204/// Find *every* occurrence of `open` followed by `close`, returning
205/// the spans in source order.
206fn scan_all_between(haystack: &str, open: &str, close: &str) -> Vec<String> {
207    let mut out = Vec::new();
208    let mut cursor = 0;
209    while let Some(rel) = haystack[cursor..].find(open) {
210        let abs = cursor + rel;
211        let after_open = abs + open.len();
212        let Some(rel_close) = haystack[after_open..].find(close) else {
213            break;
214        };
215        let abs_close = after_open + rel_close;
216        let token = haystack[after_open..abs_close].trim().to_owned();
217        if !token.is_empty() {
218            out.push(token);
219        }
220        cursor = abs_close + close.len();
221    }
222    out
223}
224
225/// Render the snapshot for the CLI.
226pub fn render_info(info: &ProjectInfo) -> String {
227    let mut out = String::new();
228    out.push_str(&format!(
229        "kick-rs project: {pkg} {ver}\n",
230        pkg = info.package_name,
231        ver = info.package_version,
232    ));
233    out.push_str(&format!("  root:        {}\n", info.root.display()));
234    out.push_str(&format!(
235        "  kick-rs dep: {}\n",
236        info.kick_rs_version
237            .as_deref()
238            .unwrap_or("<not depended on>"),
239    ));
240    out.push_str(&format!(
241        "  features:    {}\n",
242        if info.kick_rs_features.is_empty() {
243            "<none>".to_owned()
244        } else {
245            info.kick_rs_features.join(", ")
246        }
247    ));
248    out.push('\n');
249
250    if info.modules.is_empty() {
251        out.push_str("modules:       <none detected under src/modules/>\n");
252    } else {
253        out.push_str(&format!("modules ({}):\n", info.modules.len()));
254        for m in &info.modules {
255            let label = match (&m.declared_name, &m.prefix) {
256                (Some(name), Some(prefix)) => format!("{name} (prefix {prefix})"),
257                (Some(name), None) => format!("{name} (no prefix)"),
258                (None, _) => format!("{} (couldn't parse define_module)", m.dir_name),
259            };
260            out.push_str(&format!("  - {}\n", label));
261            if !m.services.is_empty() {
262                out.push_str(&format!("      services:     {}\n", m.services.join(", ")));
263            }
264            if !m.contributors.is_empty() {
265                out.push_str(&format!(
266                    "      contributors: {}\n",
267                    m.contributors.join(", ")
268                ));
269            }
270        }
271    }
272    out
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn extract_module_info_pulls_name_prefix_services_contribs() {
281        let body = r#"
282            pub mod handlers;
283            use kick_rs::{define_module, Module};
284            use email_sender::EmailSender;
285            use load_user::LoadUser;
286
287            pub fn define() -> Module {
288                define_module("users")
289                    .prefix("/users")
290                    .service::<EmailSender>()
291                    .contribute(LoadUser)
292                    .get("/", handlers::index)
293                    .build()
294            }
295        "#;
296        let m = extract_module_info("users".into(), body);
297        assert_eq!(m.declared_name.as_deref(), Some("users"));
298        assert_eq!(m.prefix.as_deref(), Some("/users"));
299        assert_eq!(m.services, vec!["EmailSender"]);
300        assert_eq!(m.contributors, vec!["LoadUser"]);
301    }
302
303    #[test]
304    fn extract_module_info_handles_multiple_services_and_contribs() {
305        let body = r#"
306            define_module("hub")
307                .prefix("/hub")
308                .service::<A>()
309                .service::<B>()
310                .contribute(X)
311                .contribute(Y)
312                .build()
313        "#;
314        let m = extract_module_info("hub".into(), body);
315        assert_eq!(m.services, vec!["A", "B"]);
316        assert_eq!(m.contributors, vec!["X", "Y"]);
317    }
318
319    #[test]
320    fn extract_module_info_tolerates_missing_define_module() {
321        // Adopter wrote a custom builder wrapper — we still want a row.
322        let body = "// no define_module here\n";
323        let m = extract_module_info("oddball".into(), body);
324        assert_eq!(m.declared_name, None);
325        assert_eq!(m.prefix, None);
326        assert!(m.services.is_empty());
327        assert!(m.contributors.is_empty());
328    }
329
330    fn make_proj(dir: &Path) {
331        fs::create_dir_all(dir.join("src/modules/users")).unwrap();
332        fs::write(dir.join("src/modules/mod.rs"), "pub mod users;\n").unwrap();
333        fs::write(
334            dir.join("src/modules/users/mod.rs"),
335            r#"use kick_rs::{define_module, Module};
336               pub fn define() -> Module {
337                   define_module("users")
338                       .prefix("/users")
339                       .build()
340               }
341            "#,
342        )
343        .unwrap();
344        fs::write(
345            dir.join("Cargo.toml"),
346            r#"[package]
347name = "demo"
348version = "0.4.2"
349edition = "2021"
350
351[dependencies]
352kick-rs = { version = "0.1.0-alpha.3", features = ["macros", "openapi"] }
353"#,
354        )
355        .unwrap();
356    }
357
358    #[test]
359    fn collect_info_reports_package_dep_and_modules() {
360        let tmp = tempfile::tempdir().unwrap();
361        let root = tmp.path().join("proj");
362        make_proj(&root);
363
364        let info = collect_info(&InfoArgs {
365            project_root: Some(root.clone()),
366            dep_name: "kick-rs".into(),
367        })
368        .unwrap();
369
370        assert_eq!(info.package_name, "demo");
371        assert_eq!(info.package_version, "0.4.2");
372        assert_eq!(info.kick_rs_version.as_deref(), Some("0.1.0-alpha.3"));
373        assert_eq!(info.kick_rs_features, vec!["macros", "openapi"]);
374        assert_eq!(info.modules.len(), 1);
375        assert_eq!(info.modules[0].dir_name, "users");
376        assert_eq!(info.modules[0].declared_name.as_deref(), Some("users"));
377        assert_eq!(info.modules[0].prefix.as_deref(), Some("/users"));
378    }
379
380    #[test]
381    fn collect_info_handles_string_dep_form() {
382        let tmp = tempfile::tempdir().unwrap();
383        let root = tmp.path().join("proj");
384        make_proj(&root);
385        // Replace inline-table with string form to exercise both shapes.
386        fs::write(
387            root.join("Cargo.toml"),
388            r#"[package]
389name = "demo"
390version = "0.1.0"
391
392[dependencies]
393kick-rs = "0.2.0"
394"#,
395        )
396        .unwrap();
397
398        let info = collect_info(&InfoArgs {
399            project_root: Some(root.clone()),
400            dep_name: "kick-rs".into(),
401        })
402        .unwrap();
403        assert_eq!(info.kick_rs_version.as_deref(), Some("0.2.0"));
404        assert!(info.kick_rs_features.is_empty());
405    }
406
407    #[test]
408    fn render_info_includes_key_fields() {
409        let info = ProjectInfo {
410            root: PathBuf::from("/x"),
411            package_name: "demo".into(),
412            package_version: "0.1.0".into(),
413            kick_rs_version: Some("0.1.0-alpha.3".into()),
414            kick_rs_features: vec!["macros".into(), "openapi".into()],
415            modules: vec![ModuleInfo {
416                dir_name: "users".into(),
417                declared_name: Some("users".into()),
418                prefix: Some("/users".into()),
419                services: vec!["S".into()],
420                contributors: vec!["C".into()],
421            }],
422        };
423        let s = render_info(&info);
424        assert!(s.contains("kick-rs project: demo 0.1.0"));
425        assert!(s.contains("kick-rs dep: 0.1.0-alpha.3"));
426        assert!(s.contains("features:    macros, openapi"));
427        assert!(s.contains("- users (prefix /users)"));
428        assert!(s.contains("services:     S"));
429        assert!(s.contains("contributors: C"));
430    }
431}