Skip to main content

ferro_cli/
project.rs

1//! Shared project introspection helpers used by deploy-scaffold commands
2//! (`docker:init`, `do:init`) and other ferro-cli commands that need to read
3//! `Cargo.toml`, detect optional project directories, or resolve the Rust
4//! toolchain version.
5//!
6//! All helpers are deliberately tolerant of malformed or missing input: they
7//! return safe defaults rather than propagating errors, mirroring the legacy
8//! `get_package_name()` behavior they replace.
9
10#![allow(dead_code)] // Consumed by plans 122-02..07; tests cover the full surface.
11
12use std::collections::BTreeMap;
13use std::fs;
14use std::io;
15use std::path::{Path, PathBuf};
16use toml::Value;
17
18const DEFAULT_RUST_IMAGE: &str = "rust:1.88-slim-bookworm";
19const DEFAULT_PACKAGE_NAME: &str = "app";
20
21/// Phase 122.2 §1: provider-neutral Dockerfile inputs declared by the project
22/// in `[package.metadata.ferro.deploy]`.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct FerroDeployMetadata {
25    pub runtime_apt: Vec<String>,
26    pub copy_dirs: Vec<String>,
27    pub ferro_version: Option<String>,
28    // Schema-only reservation for per-crate version overrides. Currently
29    // parsed, round-tripped, and ignored. Phase 130 retired the path→version
30    // rewriter that used to consume these; a future deploy path may
31    // resurrect the field. See
32    // .planning/phases/129-publish-workflow-refinement/.
33    pub ferro_versions: Option<BTreeMap<String, String>>,
34    pub web_bin: Option<String>,
35}
36
37impl Default for FerroDeployMetadata {
38    fn default() -> Self {
39        Self {
40            runtime_apt: vec![],
41            copy_dirs: vec![
42                "themes".into(),
43                "lang".into(),
44                "public".into(),
45                "migrations".into(),
46            ],
47            ferro_version: None,
48            ferro_versions: None,
49            web_bin: None,
50        }
51    }
52}
53
54/// Read `<root>/Cargo.toml` and return `[package.metadata.ferro.deploy]`.
55/// Missing table → defaults. Invalid field types → Err.
56pub fn read_deploy_metadata(project_root: &Path) -> anyhow::Result<FerroDeployMetadata> {
57    let path = project_root.join("Cargo.toml");
58    let content = fs::read_to_string(&path)
59        .map_err(|e| anyhow::anyhow!("failed to read {}: {e}", path.display()))?;
60    let parsed: Value = content
61        .parse()
62        .map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", path.display()))?;
63
64    let Some(table) = parsed
65        .get("package")
66        .and_then(|p| p.get("metadata"))
67        .and_then(|m| m.get("ferro"))
68        .and_then(|f| f.get("deploy"))
69    else {
70        return Ok(FerroDeployMetadata::default());
71    };
72
73    let mut meta = FerroDeployMetadata::default();
74
75    if let Some(v) = table.get("runtime_apt") {
76        let arr = v.as_array().ok_or_else(|| {
77            anyhow::anyhow!("[package.metadata.ferro.deploy].runtime_apt must be an array")
78        })?;
79        meta.runtime_apt = arr
80            .iter()
81            .map(|item| {
82                item.as_str().map(String::from).ok_or_else(|| {
83                    anyhow::anyhow!(
84                        "[package.metadata.ferro.deploy].runtime_apt entries must be strings"
85                    )
86                })
87            })
88            .collect::<anyhow::Result<Vec<_>>>()?;
89    }
90
91    if let Some(v) = table.get("copy_dirs") {
92        let arr = v.as_array().ok_or_else(|| {
93            anyhow::anyhow!("[package.metadata.ferro.deploy].copy_dirs must be an array")
94        })?;
95        meta.copy_dirs = arr
96            .iter()
97            .map(|item| {
98                item.as_str().map(String::from).ok_or_else(|| {
99                    anyhow::anyhow!(
100                        "[package.metadata.ferro.deploy].copy_dirs entries must be strings"
101                    )
102                })
103            })
104            .collect::<anyhow::Result<Vec<_>>>()?;
105    }
106
107    if let Some(v) = table.get("ferro_version") {
108        let s = v.as_str().ok_or_else(|| {
109            anyhow::anyhow!("[package.metadata.ferro.deploy].ferro_version must be a string")
110        })?;
111        meta.ferro_version = Some(s.to_string());
112    }
113
114    if let Some(v) = table.get("ferro_versions") {
115        let t = v.as_table().ok_or_else(|| {
116            anyhow::anyhow!("[package.metadata.ferro.deploy].ferro_versions must be a table")
117        })?;
118        let mut map = BTreeMap::new();
119        for (k, val) in t {
120            let s = val.as_str().ok_or_else(|| {
121                anyhow::anyhow!(
122                    "[package.metadata.ferro.deploy].ferro_versions.{k} must be a string"
123                )
124            })?;
125            map.insert(k.clone(), s.to_string());
126        }
127        meta.ferro_versions = Some(map);
128    }
129
130    if let Some(v) = table.get("web_bin") {
131        let s = v.as_str().ok_or_else(|| {
132            anyhow::anyhow!("[package.metadata.ferro.deploy].web_bin must be a string")
133        })?;
134        meta.web_bin = Some(s.to_string());
135    }
136
137    Ok(meta)
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct BinEntry {
142    pub name: String,
143    pub path: Option<String>,
144}
145
146#[derive(Debug, Clone, Default, PartialEq, Eq)]
147pub struct ProjectDirs {
148    pub has_frontend: bool,
149    pub has_themes: bool,
150    pub has_lang: bool,
151    pub has_public: bool,
152    pub has_migrations: bool,
153}
154
155/// Walk up from `start` (or the current working directory if `None`) until a
156/// directory containing `Cargo.toml` is found. Returns `NotFound` otherwise.
157pub fn find_project_root(start: Option<&Path>) -> io::Result<PathBuf> {
158    let mut dir = match start {
159        Some(p) => p.to_path_buf(),
160        None => std::env::current_dir()?,
161    };
162
163    loop {
164        if dir.join("Cargo.toml").is_file() {
165            return Ok(dir);
166        }
167        if !dir.pop() {
168            return Err(io::Error::new(
169                io::ErrorKind::NotFound,
170                "Cargo.toml not found (searched upward from start)",
171            ));
172        }
173    }
174}
175
176/// Parse `<root>/Cargo.toml` and return the `[package] name`. Returns
177/// `"app"` on any failure (missing file, parse error, missing key).
178pub fn package_name(root: &Path) -> String {
179    let parsed = match read_cargo_toml(root) {
180        Some(v) => v,
181        None => return DEFAULT_PACKAGE_NAME.to_string(),
182    };
183
184    parsed
185        .get("package")
186        .and_then(|p| p.get("name"))
187        .and_then(|n| n.as_str())
188        .unwrap_or(DEFAULT_PACKAGE_NAME)
189        .to_string()
190}
191
192/// Parse `<root>/Cargo.toml` and return every `[[bin]]` entry. If no
193/// `[[bin]]` table exists, synthesize one entry from `[package] name`.
194/// Returns an empty Vec only if `Cargo.toml` is unreadable.
195pub fn read_bins(root: &Path) -> Vec<BinEntry> {
196    let parsed = match read_cargo_toml(root) {
197        Some(v) => v,
198        None => return Vec::new(),
199    };
200
201    let bins = parsed
202        .get("bin")
203        .and_then(|b| b.as_array())
204        .map(|arr| {
205            arr.iter()
206                .filter_map(|entry| {
207                    let name = entry.get("name").and_then(|n| n.as_str())?.to_string();
208                    let path = entry.get("path").and_then(|p| p.as_str()).map(String::from);
209                    Some(BinEntry { name, path })
210                })
211                .collect::<Vec<_>>()
212        })
213        .unwrap_or_default();
214
215    if bins.is_empty() {
216        return vec![BinEntry {
217            name: parsed
218                .get("package")
219                .and_then(|p| p.get("name"))
220                .and_then(|n| n.as_str())
221                .unwrap_or(DEFAULT_PACKAGE_NAME)
222                .to_string(),
223            path: None,
224        }];
225    }
226
227    bins
228}
229
230/// Parse `<root>/Cargo.toml` and return `[workspace] members` verbatim.
231/// Returns an empty Vec when no `[workspace]` table exists or parsing fails.
232pub fn read_workspace_members(root: &Path) -> Vec<String> {
233    let parsed = match read_cargo_toml(root) {
234        Some(v) => v,
235        None => return Vec::new(),
236    };
237
238    parsed
239        .get("workspace")
240        .and_then(|w| w.get("members"))
241        .and_then(|m| m.as_array())
242        .map(|arr| {
243            arr.iter()
244                .filter_map(|v| v.as_str().map(String::from))
245                .collect()
246        })
247        .unwrap_or_default()
248}
249
250/// If `<root>/rust-toolchain.toml` exists and contains
251/// `[toolchain] channel = "X.Y.Z"`, return `rust:X.Y.Z-slim-bookworm`.
252/// Else return the hardcoded default `rust:1.88-slim-bookworm`.
253pub fn resolve_rust_base_image(root: &Path) -> String {
254    let path = root.join("rust-toolchain.toml");
255    let content = match fs::read_to_string(&path) {
256        Ok(c) => c,
257        Err(_) => return DEFAULT_RUST_IMAGE.to_string(),
258    };
259
260    let parsed: Value = match content.parse() {
261        Ok(v) => v,
262        Err(_) => return DEFAULT_RUST_IMAGE.to_string(),
263    };
264
265    parsed
266        .get("toolchain")
267        .and_then(|t| t.get("channel"))
268        .and_then(|c| c.as_str())
269        .map(|channel| format!("rust:{channel}-slim-bookworm"))
270        .unwrap_or_else(|| DEFAULT_RUST_IMAGE.to_string())
271}
272
273/// Probe the project root for the optional directories used by the deploy
274/// scaffold templates.
275pub fn detect_dirs(root: &Path) -> ProjectDirs {
276    ProjectDirs {
277        has_frontend: root.join("frontend/package.json").is_file(),
278        has_themes: root.join("themes").is_dir(),
279        has_lang: root.join("lang").is_dir(),
280        has_public: root.join("public").is_dir(),
281        has_migrations: root.join("migrations").is_dir(),
282    }
283}
284
285fn read_cargo_toml(root: &Path) -> Option<Value> {
286    let content = fs::read_to_string(root.join("Cargo.toml")).ok()?;
287    content.parse::<Value>().ok()
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use std::fs;
294    use tempfile::TempDir;
295
296    fn write(root: &Path, rel: &str, content: &str) {
297        let path = root.join(rel);
298        if let Some(parent) = path.parent() {
299            fs::create_dir_all(parent).unwrap();
300        }
301        fs::write(path, content).unwrap();
302    }
303
304    #[test]
305    fn find_project_root_walks_up_to_cargo_toml() {
306        let tmp = TempDir::new().unwrap();
307        let root = tmp.path();
308        write(root, "Cargo.toml", "[package]\nname = \"x\"\n");
309        let nested = root.join("a/b/c");
310        fs::create_dir_all(&nested).unwrap();
311
312        let found = find_project_root(Some(&nested)).unwrap();
313        assert_eq!(found, root);
314    }
315
316    #[test]
317    fn find_project_root_returns_not_found_when_absent() {
318        let tmp = TempDir::new().unwrap();
319        let nested = tmp.path().join("a/b");
320        fs::create_dir_all(&nested).unwrap();
321
322        let err = find_project_root(Some(&nested)).unwrap_err();
323        assert_eq!(err.kind(), io::ErrorKind::NotFound);
324    }
325
326    #[test]
327    fn package_name_parses_valid_cargo_toml() {
328        let tmp = TempDir::new().unwrap();
329        write(tmp.path(), "Cargo.toml", "[package]\nname = \"foo\"\n");
330        assert_eq!(package_name(tmp.path()), "foo");
331    }
332
333    #[test]
334    fn package_name_falls_back_to_app() {
335        let tmp = TempDir::new().unwrap();
336        assert_eq!(package_name(tmp.path()), "app");
337    }
338
339    #[test]
340    fn read_bins_returns_explicit_entries() {
341        let tmp = TempDir::new().unwrap();
342        write(
343            tmp.path(),
344            "Cargo.toml",
345            r#"
346[package]
347name = "multi"
348
349[[bin]]
350name = "alpha"
351
352[[bin]]
353name = "beta"
354path = "src/bin/beta.rs"
355"#,
356        );
357
358        let bins = read_bins(tmp.path());
359        assert_eq!(bins.len(), 2);
360        assert_eq!(
361            bins[0],
362            BinEntry {
363                name: "alpha".into(),
364                path: None
365            }
366        );
367        assert_eq!(
368            bins[1],
369            BinEntry {
370                name: "beta".into(),
371                path: Some("src/bin/beta.rs".into()),
372            }
373        );
374    }
375
376    #[test]
377    fn read_bins_synthesizes_from_package_when_no_bin_table() {
378        let tmp = TempDir::new().unwrap();
379        write(tmp.path(), "Cargo.toml", "[package]\nname = \"solo\"\n");
380        let bins = read_bins(tmp.path());
381        assert_eq!(
382            bins,
383            vec![BinEntry {
384                name: "solo".into(),
385                path: None
386            }]
387        );
388    }
389
390    #[test]
391    fn read_bins_returns_empty_when_cargo_toml_missing() {
392        let tmp = TempDir::new().unwrap();
393        assert!(read_bins(tmp.path()).is_empty());
394    }
395
396    #[test]
397    fn read_workspace_members_returns_declared_members() {
398        let tmp = TempDir::new().unwrap();
399        write(
400            tmp.path(),
401            "Cargo.toml",
402            "[workspace]\nmembers = [\"a\", \"b\"]\n",
403        );
404        assert_eq!(
405            read_workspace_members(tmp.path()),
406            vec!["a".to_string(), "b".to_string()]
407        );
408    }
409
410    #[test]
411    fn read_workspace_members_empty_without_workspace_table() {
412        let tmp = TempDir::new().unwrap();
413        write(tmp.path(), "Cargo.toml", "[package]\nname = \"x\"\n");
414        assert!(read_workspace_members(tmp.path()).is_empty());
415    }
416
417    #[test]
418    fn resolve_rust_base_image_uses_toolchain_channel() {
419        let tmp = TempDir::new().unwrap();
420        write(
421            tmp.path(),
422            "rust-toolchain.toml",
423            "[toolchain]\nchannel = \"1.90.0\"\n",
424        );
425        assert_eq!(
426            resolve_rust_base_image(tmp.path()),
427            "rust:1.90.0-slim-bookworm"
428        );
429    }
430
431    #[test]
432    fn resolve_rust_base_image_falls_back_when_missing() {
433        let tmp = TempDir::new().unwrap();
434        assert_eq!(resolve_rust_base_image(tmp.path()), DEFAULT_RUST_IMAGE);
435    }
436
437    #[test]
438    fn detect_dirs_reports_present_directories_only() {
439        let tmp = TempDir::new().unwrap();
440        fs::create_dir_all(tmp.path().join("themes")).unwrap();
441        fs::create_dir_all(tmp.path().join("public")).unwrap();
442
443        let dirs = detect_dirs(tmp.path());
444        assert_eq!(
445            dirs,
446            ProjectDirs {
447                has_themes: true,
448                has_public: true,
449                ..Default::default()
450            }
451        );
452    }
453
454    #[test]
455    fn read_deploy_metadata_full_table() {
456        let tmp = TempDir::new().unwrap();
457        write(
458            tmp.path(),
459            "Cargo.toml",
460            r#"
461[package]
462name = "x"
463
464[package.metadata.ferro.deploy]
465runtime_apt = ["chromium", "fonts-liberation"]
466copy_dirs = ["themes", "public"]
467ferro_version = "0.1.87"
468"#,
469        );
470        let m = read_deploy_metadata(tmp.path()).unwrap();
471        assert_eq!(m.runtime_apt, vec!["chromium", "fonts-liberation"]);
472        assert_eq!(m.copy_dirs, vec!["themes", "public"]);
473        assert_eq!(m.ferro_version.as_deref(), Some("0.1.87"));
474    }
475
476    #[test]
477    fn read_deploy_metadata_partial_uses_defaults() {
478        let tmp = TempDir::new().unwrap();
479        write(
480            tmp.path(),
481            "Cargo.toml",
482            r#"
483[package]
484name = "x"
485
486[package.metadata.ferro.deploy]
487runtime_apt = ["chromium"]
488"#,
489        );
490        let m = read_deploy_metadata(tmp.path()).unwrap();
491        assert_eq!(m.runtime_apt, vec!["chromium"]);
492        assert_eq!(m.copy_dirs, vec!["themes", "lang", "public", "migrations"]);
493        assert_eq!(m.ferro_version, None);
494    }
495
496    #[test]
497    fn read_deploy_metadata_missing_table_returns_default() {
498        let tmp = TempDir::new().unwrap();
499        write(tmp.path(), "Cargo.toml", "[package]\nname = \"x\"\n");
500        let m = read_deploy_metadata(tmp.path()).unwrap();
501        assert_eq!(m, FerroDeployMetadata::default());
502    }
503
504    #[test]
505    fn read_deploy_metadata_invalid_type_errors() {
506        let tmp = TempDir::new().unwrap();
507        write(
508            tmp.path(),
509            "Cargo.toml",
510            r#"
511[package]
512name = "x"
513
514[package.metadata.ferro.deploy]
515runtime_apt = "not-an-array"
516"#,
517        );
518        assert!(read_deploy_metadata(tmp.path()).is_err());
519    }
520
521    #[test]
522    fn parses_ferro_versions_override() {
523        let tmp = TempDir::new().unwrap();
524        write(
525            tmp.path(),
526            "Cargo.toml",
527            r#"
528[package]
529name = "x"
530
531[package.metadata.ferro.deploy]
532ferro_version = "0.2.0"
533
534[package.metadata.ferro.deploy.ferro_versions]
535ferro-json-ui = "0.2.1"
536ferro-whatsapp = "0.2.0"
537"#,
538        );
539        let m = read_deploy_metadata(tmp.path()).unwrap();
540        assert_eq!(m.ferro_version.as_deref(), Some("0.2.0"));
541        let overrides = m.ferro_versions.expect("ferro_versions parsed");
542        assert_eq!(
543            overrides.get("ferro-json-ui").map(String::as_str),
544            Some("0.2.1")
545        );
546        assert_eq!(
547            overrides.get("ferro-whatsapp").map(String::as_str),
548            Some("0.2.0")
549        );
550    }
551
552    #[test]
553    fn rejects_ferro_versions_wrong_type() {
554        // Case A: not a table at all.
555        let tmp = TempDir::new().unwrap();
556        write(
557            tmp.path(),
558            "Cargo.toml",
559            r#"
560[package]
561name = "x"
562
563[package.metadata.ferro.deploy]
564ferro_versions = "not-a-table"
565"#,
566        );
567        let err = read_deploy_metadata(tmp.path()).unwrap_err().to_string();
568        assert!(
569            err.contains("ferro_versions must be a table"),
570            "unexpected error: {err}"
571        );
572
573        // Case B: entry value is not a string.
574        let tmp2 = TempDir::new().unwrap();
575        write(
576            tmp2.path(),
577            "Cargo.toml",
578            r#"
579[package]
580name = "x"
581
582[package.metadata.ferro.deploy.ferro_versions]
583ferro-json-ui = 1
584"#,
585        );
586        let err2 = read_deploy_metadata(tmp2.path()).unwrap_err().to_string();
587        assert!(
588            err2.contains("ferro_versions.ferro-json-ui must be a string"),
589            "unexpected error: {err2}"
590        );
591    }
592
593    #[test]
594    fn detect_dirs_has_frontend_requires_package_json_file() {
595        let tmp = TempDir::new().unwrap();
596        fs::create_dir_all(tmp.path().join("frontend")).unwrap();
597        assert!(!detect_dirs(tmp.path()).has_frontend);
598
599        write(tmp.path(), "frontend/package.json", "{}");
600        assert!(detect_dirs(tmp.path()).has_frontend);
601    }
602}