1#![allow(dead_code)] use 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#[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 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
54pub 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
155pub 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
176pub 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
192pub 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
230pub 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
250pub 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
273pub 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 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 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}