1use anyhow::{anyhow, Context, Result};
23use std::path::{Path, PathBuf};
24use whisker_config::Config;
25
26use crate::probe;
27
28#[derive(Debug)]
30pub struct ResolvedManifest {
31 pub crate_dir: PathBuf,
35 pub package: String,
39 pub config: Config,
43}
44
45pub fn resolve(cargo_toml_override: Option<&Path>) -> Result<ResolvedManifest> {
48 let cargo_toml = match cargo_toml_override {
49 Some(p) => p.to_path_buf(),
50 None => {
51 let cwd = std::env::current_dir().context("read cwd")?;
52 find_package_cargo_toml(&cwd).ok_or_else(|| {
53 anyhow!(
54 "no `[package]` Cargo.toml at or above {} — pass `--manifest-path <path>` to point at the user crate",
55 cwd.display(),
56 )
57 })?
58 }
59 };
60 let cargo_toml = std::fs::canonicalize(&cargo_toml)
66 .with_context(|| format!("canonicalize {}", cargo_toml.display()))?;
67 let crate_dir = cargo_toml
68 .parent()
69 .ok_or_else(|| anyhow!("Cargo.toml has no parent dir: {}", cargo_toml.display()))?
70 .to_path_buf();
71 let package = parse_package_name(&cargo_toml)?;
72 let whisker_rs = crate_dir.join("whisker.rs");
73 if !whisker_rs.is_file() {
74 anyhow::bail!(
75 "no whisker.rs next to {} — every Whisker app needs a `whisker.rs` at the crate root that defines `fn configure(app: &mut Config)`",
76 cargo_toml.display(),
77 );
78 }
79 let config = probe::run(&whisker_rs, &crate_dir, &package)?;
80 Ok(ResolvedManifest {
81 crate_dir,
82 package,
83 config,
84 })
85}
86
87fn find_package_cargo_toml(start: &Path) -> Option<PathBuf> {
92 let mut cur = start.to_path_buf();
93 loop {
94 let cargo = cur.join("Cargo.toml");
95 if cargo.is_file() {
96 if let Ok(txt) = std::fs::read_to_string(&cargo) {
97 if has_package_section(&txt) {
98 return Some(cargo);
99 }
100 }
101 }
102 if !cur.pop() {
103 return None;
104 }
105 }
106}
107
108fn has_package_section(toml_text: &str) -> bool {
112 toml_text.lines().any(|line| {
113 let l = line.trim();
114 l == "[package]" || l.starts_with("[package]") || l == "[ package ]"
115 })
116}
117
118fn parse_package_name(cargo_toml: &Path) -> Result<String> {
120 let text = std::fs::read_to_string(cargo_toml)
121 .with_context(|| format!("read {}", cargo_toml.display()))?;
122 let doc: toml::Value =
123 toml::from_str(&text).with_context(|| format!("parse {} as TOML", cargo_toml.display()))?;
124 let name = doc
125 .get("package")
126 .and_then(|p| p.get("name"))
127 .and_then(|n| n.as_str())
128 .ok_or_else(|| {
129 anyhow!(
130 "{} has no [package].name (is this a virtual-workspace Cargo.toml?)",
131 cargo_toml.display(),
132 )
133 })?;
134 Ok(name.to_string())
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use std::sync::atomic::{AtomicU64, Ordering};
141
142 fn unique_tempdir(label: &str) -> PathBuf {
143 static SEQ: AtomicU64 = AtomicU64::new(0);
144 let n = SEQ.fetch_add(1, Ordering::Relaxed);
145 let pid = std::process::id();
146 let p = std::env::temp_dir().join(format!("whisker-cli-manifest-{label}-{pid}-{n}"));
147 let _ = std::fs::remove_dir_all(&p);
148 std::fs::create_dir_all(&p).unwrap();
149 p
150 }
151
152 #[test]
153 fn has_package_section_detects_the_table_header() {
154 assert!(has_package_section("[package]\nname = \"x\"\n"));
155 assert!(has_package_section("\n\n[package]\n"));
156 assert!(!has_package_section("[workspace]\nmembers = []\n"));
157 assert!(!has_package_section("[package.metadata.foo]\nbar = 1\n"));
158 }
159
160 #[test]
161 fn find_package_cargo_toml_skips_virtual_workspace_root() {
162 let tmp = unique_tempdir("vws");
163 std::fs::write(tmp.join("Cargo.toml"), "[workspace]\nmembers = [\"app\"]\n").unwrap();
164 let app = tmp.join("app");
165 std::fs::create_dir_all(&app).unwrap();
166 std::fs::write(
167 app.join("Cargo.toml"),
168 "[package]\nname = \"app\"\nversion = \"0.0.0\"\n",
169 )
170 .unwrap();
171 assert_eq!(
174 find_package_cargo_toml(&app).as_deref(),
175 Some(app.join("Cargo.toml").as_path()),
176 );
177 let _ = std::fs::remove_dir_all(&tmp);
178 }
179
180 #[test]
181 fn parse_package_name_reads_the_name_field() {
182 let tmp = unique_tempdir("name");
183 let p = tmp.join("Cargo.toml");
184 std::fs::write(
185 &p,
186 "[package]\nname = \"my-cool-app\"\nversion = \"0.0.0\"\n",
187 )
188 .unwrap();
189 assert_eq!(parse_package_name(&p).unwrap(), "my-cool-app");
190 let _ = std::fs::remove_dir_all(&tmp);
191 }
192}