use crate::generate::{find_project_root, GenerateError};
use std::fs;
use std::path::{Path, PathBuf};
use toml_edit::{DocumentMut, Value};
pub struct InfoArgs {
pub project_root: Option<PathBuf>,
pub dep_name: String,
}
#[derive(Debug)]
pub struct ProjectInfo {
pub root: PathBuf,
pub package_name: String,
pub package_version: String,
pub kick_rs_version: Option<String>,
pub kick_rs_features: Vec<String>,
pub modules: Vec<ModuleInfo>,
}
#[derive(Debug)]
pub struct ModuleInfo {
pub dir_name: String,
pub declared_name: Option<String>,
pub prefix: Option<String>,
pub services: Vec<String>,
pub contributors: Vec<String>,
}
#[derive(Debug)]
pub enum InfoError {
Io {
path: PathBuf,
source: std::io::Error,
},
Toml {
path: PathBuf,
source: Box<toml_edit::TomlError>,
},
ProjectRoot(GenerateError),
}
impl std::fmt::Display for InfoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { path, source } => write!(f, "I/O error at `{}`: {source}", path.display()),
Self::Toml { path, source } => {
write!(f, "could not parse `{}`: {source}", path.display())
}
Self::ProjectRoot(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for InfoError {}
pub fn collect_info(args: &InfoArgs) -> Result<ProjectInfo, InfoError> {
let root = match &args.project_root {
Some(p) => p.clone(),
None => find_project_root(Path::new(".")).map_err(InfoError::ProjectRoot)?,
};
let cargo_toml = root.join("Cargo.toml");
let contents = fs::read_to_string(&cargo_toml).map_err(|e| InfoError::Io {
path: cargo_toml.clone(),
source: e,
})?;
let doc: DocumentMut = contents.parse().map_err(|e| InfoError::Toml {
path: cargo_toml.clone(),
source: Box::new(e),
})?;
let pkg = doc
.get("package")
.and_then(|i| i.as_table_like())
.ok_or_else(|| InfoError::Io {
path: cargo_toml.clone(),
source: std::io::Error::other("Cargo.toml has no [package] table"),
})?;
let package_name = pkg
.get("name")
.and_then(|i| i.as_str())
.unwrap_or("<unknown>")
.to_owned();
let package_version = pkg
.get("version")
.and_then(|i| i.as_str())
.unwrap_or("<unknown>")
.to_owned();
let (kick_rs_version, kick_rs_features) = doc
.get("dependencies")
.and_then(|i| i.as_table_like())
.and_then(|t| t.get(&args.dep_name))
.map(|item| match item {
toml_edit::Item::Value(Value::String(s)) => (Some(s.value().to_owned()), Vec::new()),
toml_edit::Item::Value(Value::InlineTable(t)) => {
let v = t.get("version").and_then(|x| x.as_str()).map(str::to_owned);
let feats = t
.get("features")
.and_then(|x| x.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
(v, feats)
}
_ => (None, Vec::new()),
})
.unwrap_or((None, Vec::new()));
let modules = collect_modules(&root.join("src/modules"));
Ok(ProjectInfo {
root,
package_name,
package_version,
kick_rs_version,
kick_rs_features,
modules,
})
}
fn collect_modules(modules_dir: &Path) -> Vec<ModuleInfo> {
let Ok(entries) = fs::read_dir(modules_dir) else {
return Vec::new();
};
let mut out: Vec<ModuleInfo> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let path = e.path();
if !path.is_dir() {
return None;
}
let dir_name = path.file_name()?.to_str()?.to_owned();
let mod_rs = path.join("mod.rs");
let body = fs::read_to_string(&mod_rs).ok()?;
Some(extract_module_info(dir_name, &body))
})
.collect();
out.sort_by(|a, b| a.dir_name.cmp(&b.dir_name));
out
}
pub(crate) fn extract_module_info(dir_name: String, body: &str) -> ModuleInfo {
let declared_name = scan_between(body, "define_module(\"", "\"");
let prefix = scan_between(body, ".prefix(\"", "\"");
let services = scan_all_between(body, ".service::<", ">()");
let contributors = scan_all_between(body, ".contribute(", ")");
ModuleInfo {
dir_name,
declared_name,
prefix,
services,
contributors,
}
}
fn scan_between(haystack: &str, open: &str, close: &str) -> Option<String> {
let start = haystack.find(open)?;
let after_open = &haystack[start + open.len()..];
let end = after_open.find(close)?;
Some(after_open[..end].trim().to_owned())
}
fn scan_all_between(haystack: &str, open: &str, close: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cursor = 0;
while let Some(rel) = haystack[cursor..].find(open) {
let abs = cursor + rel;
let after_open = abs + open.len();
let Some(rel_close) = haystack[after_open..].find(close) else {
break;
};
let abs_close = after_open + rel_close;
let token = haystack[after_open..abs_close].trim().to_owned();
if !token.is_empty() {
out.push(token);
}
cursor = abs_close + close.len();
}
out
}
pub fn render_info(info: &ProjectInfo) -> String {
let mut out = String::new();
out.push_str(&format!(
"kick-rs project: {pkg} {ver}\n",
pkg = info.package_name,
ver = info.package_version,
));
out.push_str(&format!(" root: {}\n", info.root.display()));
out.push_str(&format!(
" kick-rs dep: {}\n",
info.kick_rs_version
.as_deref()
.unwrap_or("<not depended on>"),
));
out.push_str(&format!(
" features: {}\n",
if info.kick_rs_features.is_empty() {
"<none>".to_owned()
} else {
info.kick_rs_features.join(", ")
}
));
out.push('\n');
if info.modules.is_empty() {
out.push_str("modules: <none detected under src/modules/>\n");
} else {
out.push_str(&format!("modules ({}):\n", info.modules.len()));
for m in &info.modules {
let label = match (&m.declared_name, &m.prefix) {
(Some(name), Some(prefix)) => format!("{name} (prefix {prefix})"),
(Some(name), None) => format!("{name} (no prefix)"),
(None, _) => format!("{} (couldn't parse define_module)", m.dir_name),
};
out.push_str(&format!(" - {}\n", label));
if !m.services.is_empty() {
out.push_str(&format!(" services: {}\n", m.services.join(", ")));
}
if !m.contributors.is_empty() {
out.push_str(&format!(
" contributors: {}\n",
m.contributors.join(", ")
));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_module_info_pulls_name_prefix_services_contribs() {
let body = r#"
pub mod handlers;
use kick_rs::{define_module, Module};
use email_sender::EmailSender;
use load_user::LoadUser;
pub fn define() -> Module {
define_module("users")
.prefix("/users")
.service::<EmailSender>()
.contribute(LoadUser)
.get("/", handlers::index)
.build()
}
"#;
let m = extract_module_info("users".into(), body);
assert_eq!(m.declared_name.as_deref(), Some("users"));
assert_eq!(m.prefix.as_deref(), Some("/users"));
assert_eq!(m.services, vec!["EmailSender"]);
assert_eq!(m.contributors, vec!["LoadUser"]);
}
#[test]
fn extract_module_info_handles_multiple_services_and_contribs() {
let body = r#"
define_module("hub")
.prefix("/hub")
.service::<A>()
.service::<B>()
.contribute(X)
.contribute(Y)
.build()
"#;
let m = extract_module_info("hub".into(), body);
assert_eq!(m.services, vec!["A", "B"]);
assert_eq!(m.contributors, vec!["X", "Y"]);
}
#[test]
fn extract_module_info_tolerates_missing_define_module() {
let body = "// no define_module here\n";
let m = extract_module_info("oddball".into(), body);
assert_eq!(m.declared_name, None);
assert_eq!(m.prefix, None);
assert!(m.services.is_empty());
assert!(m.contributors.is_empty());
}
fn make_proj(dir: &Path) {
fs::create_dir_all(dir.join("src/modules/users")).unwrap();
fs::write(dir.join("src/modules/mod.rs"), "pub mod users;\n").unwrap();
fs::write(
dir.join("src/modules/users/mod.rs"),
r#"use kick_rs::{define_module, Module};
pub fn define() -> Module {
define_module("users")
.prefix("/users")
.build()
}
"#,
)
.unwrap();
fs::write(
dir.join("Cargo.toml"),
r#"[package]
name = "demo"
version = "0.4.2"
edition = "2021"
[dependencies]
kick-rs = { version = "0.1.0-alpha.3", features = ["macros", "openapi"] }
"#,
)
.unwrap();
}
#[test]
fn collect_info_reports_package_dep_and_modules() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
make_proj(&root);
let info = collect_info(&InfoArgs {
project_root: Some(root.clone()),
dep_name: "kick-rs".into(),
})
.unwrap();
assert_eq!(info.package_name, "demo");
assert_eq!(info.package_version, "0.4.2");
assert_eq!(info.kick_rs_version.as_deref(), Some("0.1.0-alpha.3"));
assert_eq!(info.kick_rs_features, vec!["macros", "openapi"]);
assert_eq!(info.modules.len(), 1);
assert_eq!(info.modules[0].dir_name, "users");
assert_eq!(info.modules[0].declared_name.as_deref(), Some("users"));
assert_eq!(info.modules[0].prefix.as_deref(), Some("/users"));
}
#[test]
fn collect_info_handles_string_dep_form() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
make_proj(&root);
fs::write(
root.join("Cargo.toml"),
r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
kick-rs = "0.2.0"
"#,
)
.unwrap();
let info = collect_info(&InfoArgs {
project_root: Some(root.clone()),
dep_name: "kick-rs".into(),
})
.unwrap();
assert_eq!(info.kick_rs_version.as_deref(), Some("0.2.0"));
assert!(info.kick_rs_features.is_empty());
}
#[test]
fn render_info_includes_key_fields() {
let info = ProjectInfo {
root: PathBuf::from("/x"),
package_name: "demo".into(),
package_version: "0.1.0".into(),
kick_rs_version: Some("0.1.0-alpha.3".into()),
kick_rs_features: vec!["macros".into(), "openapi".into()],
modules: vec![ModuleInfo {
dir_name: "users".into(),
declared_name: Some("users".into()),
prefix: Some("/users".into()),
services: vec!["S".into()],
contributors: vec!["C".into()],
}],
};
let s = render_info(&info);
assert!(s.contains("kick-rs project: demo 0.1.0"));
assert!(s.contains("kick-rs dep: 0.1.0-alpha.3"));
assert!(s.contains("features: macros, openapi"));
assert!(s.contains("- users (prefix /users)"));
assert!(s.contains("services: S"));
assert!(s.contains("contributors: C"));
}
}