1use crate::generate::{find_project_root, GenerateError};
17use std::fs;
18use std::path::{Path, PathBuf};
19use toml_edit::{DocumentMut, Value};
20
21pub struct InfoArgs {
23 pub project_root: Option<PathBuf>,
24 pub dep_name: String,
25}
26
27#[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 pub dir_name: String,
42 pub declared_name: Option<String>,
44 pub prefix: Option<String>,
46 pub services: Vec<String>,
48 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
79pub 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 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
172pub(crate) fn extract_module_info(dir_name: String, body: &str) -> ModuleInfo {
178 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
194fn 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
204fn 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
225pub 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 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 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}