Skip to main content

whisker_cli/
manifest.rs

1//! Resolve a user crate's `whisker.rs` config + `Cargo.toml` metadata
2//! into a [`ResolvedManifest`] the CLI can hand to `whisker-dev-server`.
3//!
4//! The dev-server itself is manifest-agnostic — it accepts flat
5//! parameters (paths, bundle ids, application ids, …) via
6//! `whisker_dev_server::Config`. Translating the user's
7//! `whisker.rs::configure(&mut Config)` result into those flat
8//! values is the CLI's job and lives in [`super::run`].
9//!
10//! ## Discovery
11//!
12//! [`resolve`] takes an optional explicit `Cargo.toml` path. When
13//! `None`, it walks up from `cwd` looking for the first `Cargo.toml`
14//! that has a `[package]` section (a `[workspace]`-only manifest at
15//! the top of a virtual workspace doesn't count — we need the
16//! package node).
17//!
18//! Once a `Cargo.toml` is found, the sibling `whisker.rs` is the
19//! config source. Missing `whisker.rs` is an error: the dev-server
20//! needs bundle id, application id, etc. that the file supplies.
21
22use anyhow::{anyhow, Context, Result};
23use std::path::{Path, PathBuf};
24use whisker_config::Config;
25
26use crate::probe;
27
28/// One CLI invocation's worth of resolved user-crate state.
29#[derive(Debug)]
30pub struct ResolvedManifest {
31    /// Directory containing the user crate's `Cargo.toml` and (next
32    /// to it) `whisker.rs`. All other paths the CLI builds are
33    /// relative to this.
34    pub crate_dir: PathBuf,
35    /// `[package].name` from `Cargo.toml`. Used by the dev-server as
36    /// the cargo `-p` argument, and by `whisker-build` to find the
37    /// `lib<package>.so` / `.dylib` artifact.
38    pub package: String,
39    /// Result of running the user's `whisker.rs::configure`. Owned
40    /// (decoded from JSON) so subsequent CLI logic can pattern-match
41    /// on optional fields without rerunning the probe.
42    pub config: Config,
43}
44
45/// Resolve the manifest. `cargo_toml_override` (set via
46/// `whisker run --manifest-path <path>`) bypasses cwd discovery.
47pub 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    // Canonicalize: downstream sites (Command::current_dir,
61    // find_workspace_root's upward walk) break when crate_dir is
62    // relative and the workspace root coincides with the process cwd —
63    // PathBuf::pop() then bottoms out at "" which feeds chdir("") and
64    // surfaces as posix-spawn ENOENT.
65    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
87/// Walk up from `start` looking for the first Cargo.toml with a
88/// `[package]` table. A pure `[workspace]` manifest at the top of a
89/// virtual workspace is skipped — we want the user-crate package,
90/// not the workspace root.
91fn 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
108/// Cheap test for `[package]` without pulling in a full TOML parser
109/// just for the walk-up phase. We do a real `toml::from_str` once
110/// we've picked a winning candidate (see `parse_package_name`).
111fn 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
118/// Parse `[package].name` from the given Cargo.toml.
119fn 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        // From inside the member, walker should land on the member's
172        // Cargo.toml, not the virtual-workspace one.
173        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}