Skip to main content

xbp_cli/utils/
mod.rs

1//! utility functions for xbp cli
2//!
3//! provides utility modules for common operations
4//! includes version api integration and other helper functions
5
6pub mod version;
7
8pub use version::{fetch_version, increment_version};
9
10use serde::de::DeserializeOwned;
11use serde_json::{Map, Value};
12use std::collections::{BTreeMap, HashMap, HashSet};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16use sysinfo::{Pid, System};
17
18use crate::profile::find_all_xbp_projects;
19
20#[derive(Debug, Clone)]
21pub struct FoundXbpConfig {
22    pub project_root: PathBuf,
23    pub config_path: PathBuf,
24    pub kind: &'static str,
25    pub location: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct KnownXbpProject {
30    pub root: PathBuf,
31    pub name: String,
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct ListeningPortOwnership {
36    pub pids: Vec<u32>,
37    pub xbp_projects: Vec<String>,
38}
39
40pub fn default_project_yaml_config_path(project_root: &Path) -> PathBuf {
41    project_root.join(".xbp").join("xbp.yaml")
42}
43
44pub fn find_existing_yaml_xbp_config(project_root: &Path) -> Option<PathBuf> {
45    let candidates = [
46        project_root.join(".xbp").join("xbp.yaml"),
47        project_root.join(".xbp").join("xbp.yml"),
48        project_root.join("xbp.yaml"),
49        project_root.join("xbp.yml"),
50    ];
51
52    candidates.into_iter().find(|candidate| candidate.exists())
53}
54
55pub fn maybe_auto_convert_legacy_xbp_json_to_yaml(
56    project_root: &Path,
57    config_path: &Path,
58) -> Result<Option<PathBuf>, String> {
59    if config_path.file_name() != Some(std::ffi::OsStr::new("xbp.json")) {
60        return Ok(None);
61    }
62
63    if !config_path.exists() {
64        return Ok(None);
65    }
66
67    if let Some(existing_yaml) = find_existing_yaml_xbp_config(project_root) {
68        return Ok(Some(existing_yaml));
69    }
70
71    let content = fs::read_to_string(config_path).map_err(|e| {
72        format!(
73            "Failed to read legacy JSON config {}: {}",
74            config_path.display(),
75            e
76        )
77    })?;
78    let value: Value = serde_json::from_str(&content)
79        .map_err(|e| format!("Failed to parse legacy JSON config: {}", e))?;
80
81    let yaml_path = default_project_yaml_config_path(project_root);
82    if let Some(parent) = yaml_path.parent() {
83        fs::create_dir_all(parent).map_err(|e| {
84            format!(
85                "Failed to create config directory {}: {}",
86                parent.display(),
87                e
88            )
89        })?;
90    }
91
92    let yaml = serde_yaml::to_string(&value)
93        .map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
94    fs::write(&yaml_path, yaml)
95        .map_err(|e| format!("Failed to write YAML config {}: {}", yaml_path.display(), e))?;
96
97    Ok(Some(yaml_path))
98}
99
100pub fn write_json_config_from_any_xbp_config(
101    config_path: &Path,
102    output_json_path: &Path,
103) -> Result<(), String> {
104    let content = fs::read_to_string(config_path)
105        .map_err(|e| format!("Failed to read config {}: {}", config_path.display(), e))?;
106
107    let kind = if config_path
108        .extension()
109        .and_then(|ext| ext.to_str())
110        .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
111        .unwrap_or(false)
112    {
113        "yaml"
114    } else {
115        "json"
116    };
117
118    let value = if kind == "yaml" {
119        let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content)
120            .map_err(|e| format!("Failed to parse YAML config: {}", e))?;
121        serde_json::to_value(yaml_value)
122            .map_err(|e| format!("Failed to convert YAML config to JSON value: {}", e))?
123    } else {
124        serde_json::from_str::<Value>(&content)
125            .map_err(|e| format!("Failed to parse JSON config: {}", e))?
126    };
127
128    if let Some(parent) = output_json_path.parent() {
129        fs::create_dir_all(parent).map_err(|e| {
130            format!(
131                "Failed to create config directory {}: {}",
132                parent.display(),
133                e
134            )
135        })?;
136    }
137
138    let rendered = serde_json::to_string_pretty(&value)
139        .map_err(|e| format!("Failed to serialize JSON config: {}", e))?;
140    fs::write(output_json_path, rendered).map_err(|e| {
141        format!(
142            "Failed to write JSON config {}: {}",
143            output_json_path.display(),
144            e
145        )
146    })?;
147
148    Ok(())
149}
150
151pub fn find_xbp_config_upwards(start_dir: &Path) -> Option<FoundXbpConfig> {
152    for dir in start_dir.ancestors() {
153        let candidates: [(PathBuf, &'static str); 6] = [
154            (dir.join(".xbp").join("xbp.yaml"), "yaml"),
155            (dir.join(".xbp").join("xbp.yml"), "yaml"),
156            (dir.join(".xbp").join("xbp.json"), "json"),
157            (dir.join("xbp.yaml"), "yaml"),
158            (dir.join("xbp.yml"), "yaml"),
159            (dir.join("xbp.json"), "json"),
160        ];
161
162        for (path, kind) in candidates {
163            if !path.exists() {
164                continue;
165            }
166
167            let project_root = path
168                .parent()
169                .and_then(|p| {
170                    if p.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
171                        p.parent().map(|pp| pp.to_path_buf())
172                    } else {
173                        Some(p.to_path_buf())
174                    }
175                })
176                .unwrap_or_else(|| dir.to_path_buf());
177
178            let location = path
179                .strip_prefix(&project_root)
180                .ok()
181                .map(|p| p.to_string_lossy().replace('\\', "/"))
182                .unwrap_or_else(|| path.to_string_lossy().replace('\\', "/"));
183
184            return Some(FoundXbpConfig {
185                project_root,
186                config_path: path,
187                kind,
188                location,
189            });
190        }
191    }
192
193    None
194}
195
196pub fn collect_known_xbp_projects() -> Vec<KnownXbpProject> {
197    let mut projects = Vec::new();
198    let mut seen_roots = HashSet::new();
199
200    for project in find_all_xbp_projects() {
201        let root = canonicalize_or_fallback(&project.path);
202        if seen_roots.insert(root.clone()) {
203            projects.push(KnownXbpProject {
204                root,
205                name: project.name,
206            });
207        }
208    }
209
210    if let Ok(current_dir) = std::env::current_dir() {
211        if let Some(found) = find_xbp_config_upwards(&current_dir) {
212            let root = canonicalize_or_fallback(&found.project_root);
213            if seen_roots.insert(root.clone()) {
214                let name = root
215                    .file_name()
216                    .and_then(|value| value.to_str())
217                    .unwrap_or("current")
218                    .to_string();
219                projects.push(KnownXbpProject { root, name });
220            }
221        }
222    }
223
224    projects.sort_by(|left, right| {
225        right
226            .root
227            .components()
228            .count()
229            .cmp(&left.root.components().count())
230            .then_with(|| left.name.cmp(&right.name))
231    });
232    projects
233}
234
235pub fn resolve_xbp_project_for_path(
236    candidate: &Path,
237    known_projects: &[KnownXbpProject],
238) -> Option<String> {
239    if candidate.as_os_str().is_empty() {
240        return None;
241    }
242
243    if let Some(found) = find_xbp_config_upwards(candidate) {
244        let found_root = canonicalize_or_fallback(&found.project_root);
245        if let Some(project) = known_projects
246            .iter()
247            .find(|project| canonicalize_or_fallback(&project.root) == found_root)
248        {
249            return Some(project.name.clone());
250        }
251
252        return found_root
253            .file_name()
254            .and_then(|value| value.to_str())
255            .map(|value| value.to_string());
256    }
257
258    let candidate_path = canonicalize_or_fallback(candidate);
259    known_projects
260        .iter()
261        .find(|project| candidate_path.starts_with(canonicalize_or_fallback(&project.root)))
262        .map(|project| project.name.clone())
263}
264
265pub fn collect_listening_port_ownership() -> Result<BTreeMap<u16, ListeningPortOwnership>, String> {
266    use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
267
268    let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
269    let proto_flags = ProtocolFlags::TCP;
270    let sockets = get_sockets_info(af_flags, proto_flags)
271        .map_err(|e| format!("Failed to get sockets info: {}", e))?;
272
273    let mut system = System::new_all();
274    system.refresh_all();
275    let known_projects = collect_known_xbp_projects();
276    let mut pid_project_cache: HashMap<u32, Option<String>> = HashMap::new();
277    let mut ports: BTreeMap<u16, ListeningPortOwnership> = BTreeMap::new();
278
279    for socket in sockets {
280        if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
281            let state = format!("{:?}", tcp.state);
282            if state != "Listen" && state != "LISTEN" {
283                continue;
284            }
285
286            let row = ports.entry(tcp.local_port).or_default();
287            for pid in socket.associated_pids {
288                row.pids.push(pid);
289                if let Some(project) = pid_project_cache
290                    .entry(pid)
291                    .or_insert_with(|| resolve_xbp_project_for_pid(pid, &system, &known_projects))
292                    .clone()
293                {
294                    row.xbp_projects.push(project);
295                }
296            }
297        }
298    }
299
300    for row in ports.values_mut() {
301        row.pids.sort_unstable();
302        row.pids.dedup();
303        row.xbp_projects.sort();
304        row.xbp_projects.dedup();
305    }
306
307    Ok(ports)
308}
309
310fn resolve_xbp_project_for_pid(
311    pid: u32,
312    system: &System,
313    known_projects: &[KnownXbpProject],
314) -> Option<String> {
315    let process = system.process(Pid::from_u32(pid))?;
316
317    if let Some(project) = process
318        .cwd()
319        .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
320    {
321        return Some(project);
322    }
323
324    process
325        .exe()
326        .and_then(|path| path.parent())
327        .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
328}
329
330fn canonicalize_or_fallback(path: &Path) -> PathBuf {
331    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
332}
333
334pub fn expand_home_in_string(input: &str) -> String {
335    let home = dirs::home_dir()
336        .unwrap_or_else(|| std::path::PathBuf::from("."))
337        .to_string_lossy()
338        .to_string();
339
340    if input == "~" {
341        return home;
342    }
343
344    if let Some(rest) = input
345        .strip_prefix("~/")
346        .or_else(|| input.strip_prefix("~\\"))
347    {
348        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
349    }
350
351    if let Some(rest) = input
352        .strip_prefix("$HOME/")
353        .or_else(|| input.strip_prefix("$HOME\\"))
354    {
355        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
356    }
357
358    if let Some(rest) = input
359        .strip_prefix("${HOME}/")
360        .or_else(|| input.strip_prefix("${HOME}\\"))
361    {
362        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
363    }
364
365    input.to_string()
366}
367
368pub fn collapse_home_to_env(input: &str) -> String {
369    let home = dirs::home_dir()
370        .unwrap_or_else(|| std::path::PathBuf::from("."))
371        .to_string_lossy()
372        .to_string();
373
374    if input == home {
375        return "$HOME".to_string();
376    }
377
378    if let Some(rest) = input.strip_prefix(&(home.clone() + "/")) {
379        return format!("$HOME/{}", rest);
380    }
381
382    if let Some(rest) = input.strip_prefix(&(home.clone() + "\\")) {
383        return format!("$HOME\\{}", rest);
384    }
385
386    input.to_string()
387}
388
389pub fn command_exists(program: &str) -> bool {
390    let Some(path_var) = std::env::var_os("PATH") else {
391        return false;
392    };
393
394    for dir in std::env::split_paths(&path_var) {
395        let candidate = dir.join(program);
396        if candidate.is_file() {
397            return true;
398        }
399
400        #[cfg(windows)]
401        for ext in ["exe", "cmd", "bat"] {
402            let candidate = dir.join(format!("{}.{}", program, ext));
403            if candidate.is_file() {
404                return true;
405            }
406        }
407    }
408
409    false
410}
411
412pub fn first_available_command(candidates: &[&str]) -> Option<String> {
413    candidates
414        .iter()
415        .find(|candidate| command_exists(candidate))
416        .map(|candidate| (*candidate).to_string())
417}
418
419pub fn preferred_python_command() -> String {
420    first_available_command(&["python3", "python"]).unwrap_or_else(|| {
421        if cfg!(target_os = "windows") {
422            "python".to_string()
423        } else {
424            "python3".to_string()
425        }
426    })
427}
428
429pub fn preferred_pip_command() -> String {
430    first_available_command(&["pip3", "pip"]).unwrap_or_else(|| {
431        if cfg!(target_os = "windows") {
432            "pip".to_string()
433        } else {
434            "pip3".to_string()
435        }
436    })
437}
438
439pub fn open_with_default_handler(target: &str) -> Result<(), String> {
440    let mut command = if cfg!(target_os = "windows") {
441        let mut cmd = Command::new("cmd");
442        cmd.arg("/C").arg("start").arg("").arg(target);
443        cmd
444    } else if cfg!(target_os = "macos") {
445        let mut cmd = Command::new("open");
446        cmd.arg(target);
447        cmd
448    } else {
449        let mut cmd = Command::new("xdg-open");
450        cmd.arg(target);
451        cmd
452    };
453
454    command
455        .spawn()
456        .map_err(|e| format!("Failed to open '{}': {}", target, e))?;
457    Ok(())
458}
459
460pub fn open_path_with_editor(path: &Path) -> Result<(), String> {
461    if let Ok(editor) = std::env::var("EDITOR") {
462        let mut parts = editor.split_whitespace();
463        let binary = parts
464            .next()
465            .ok_or_else(|| "EDITOR is set but empty".to_string())?;
466        let mut command = Command::new(binary);
467        for part in parts {
468            command.arg(part);
469        }
470        command
471            .arg(path)
472            .spawn()
473            .map_err(|e| format!("Failed to launch editor '{}': {}", editor, e))?;
474        return Ok(());
475    }
476
477    open_with_default_handler(&path.display().to_string())
478}
479
480pub fn parse_config_with_auto_heal<T: DeserializeOwned>(
481    content: &str,
482    kind: &str,
483) -> Result<(T, Option<String>), String> {
484    let mut value = match kind {
485        "yaml" => {
486            let yaml_value: serde_yaml::Value =
487                serde_yaml::from_str(content).map_err(|e| e.to_string())?;
488            serde_json::to_value(yaml_value).map_err(|e| e.to_string())?
489        }
490        "json" => serde_json::from_str::<Value>(content).map_err(|e| e.to_string())?,
491        _ => return Err(format!("Unsupported config kind: {}", kind)),
492    };
493
494    let healed = auto_heal_xbp_config_value(&mut value);
495    let parsed = serde_json::from_value::<T>(value.clone()).map_err(|e| e.to_string())?;
496
497    let healed_content = if healed {
498        Some(match kind {
499            "yaml" => serde_yaml::to_string(&value).map_err(|e| e.to_string())?,
500            "json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
501            _ => unreachable!(),
502        })
503    } else {
504        None
505    };
506
507    Ok((parsed, healed_content))
508}
509
510pub fn heal_config_file(path: &Path, kind: &str) -> Result<bool, String> {
511    let content = fs::read_to_string(path)
512        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
513    let (_, healed_content) = parse_config_with_auto_heal::<Value>(&content, kind)?;
514
515    if let Some(healed_content) = healed_content {
516        fs::write(path, healed_content)
517            .map_err(|e| format!("Failed to write healed config {}: {}", path.display(), e))?;
518        return Ok(true);
519    }
520
521    Ok(false)
522}
523
524fn auto_heal_xbp_config_value(value: &mut Value) -> bool {
525    let Some(root) = value.as_object_mut() else {
526        return false;
527    };
528
529    let mut changed = false;
530
531    if let Some(environment) = root.get_mut("environment") {
532        changed |= normalize_environment_value(environment);
533    }
534
535    if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
536        for service in services {
537            if let Some(environment) = service
538                .as_object_mut()
539                .and_then(|service| service.get_mut("environment"))
540            {
541                changed |= normalize_environment_value(environment);
542            }
543        }
544    }
545
546    changed
547}
548
549fn normalize_environment_value(value: &mut Value) -> bool {
550    let Value::Object(map) = value else {
551        return false;
552    };
553
554    let original = map.clone();
555    let mut normalized = Map::new();
556    let mut changed = false;
557
558    flatten_environment_entries(&original, &mut normalized, &mut changed);
559
560    if normalized != original {
561        *map = normalized;
562        changed = true;
563    }
564
565    changed
566}
567
568fn flatten_environment_entries(
569    source: &Map<String, Value>,
570    target: &mut Map<String, Value>,
571    changed: &mut bool,
572) {
573    for (key, value) in source {
574        match value {
575            Value::String(string) => {
576                target.insert(key.clone(), Value::String(string.clone()));
577            }
578            Value::Number(number) => {
579                *changed = true;
580                target.insert(key.clone(), Value::String(number.to_string()));
581            }
582            Value::Bool(boolean) => {
583                *changed = true;
584                target.insert(key.clone(), Value::String(boolean.to_string()));
585            }
586            Value::Null => {
587                *changed = true;
588                target.insert(key.clone(), Value::String(String::new()));
589            }
590            Value::Array(items) => {
591                *changed = true;
592                let serialized = serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string());
593                target.insert(key.clone(), Value::String(serialized));
594            }
595            Value::Object(nested) => {
596                *changed = true;
597                flatten_environment_entries(nested, target, changed);
598            }
599        }
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::{
606        command_exists, first_available_command, maybe_auto_convert_legacy_xbp_json_to_yaml,
607        parse_config_with_auto_heal, preferred_pip_command, preferred_python_command,
608        resolve_xbp_project_for_path, write_json_config_from_any_xbp_config, KnownXbpProject,
609    };
610    use crate::strategies::XbpConfig;
611    use std::fs;
612    use std::path::PathBuf;
613    use std::sync::{Mutex, OnceLock};
614
615    fn path_lock() -> &'static Mutex<()> {
616        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
617        LOCK.get_or_init(|| Mutex::new(()))
618    }
619
620    fn make_temp_path(name: &str) -> PathBuf {
621        let mut path = std::env::temp_dir();
622        path.push(format!("xbp-test-{}-{}", name, std::process::id()));
623        path
624    }
625
626    fn with_path<F>(entries: &[PathBuf], test: F)
627    where
628        F: FnOnce(),
629    {
630        let _guard = path_lock().lock().expect("path lock should be available");
631        let original = std::env::var_os("PATH");
632        let joined = std::env::join_paths(entries).expect("PATH entries should join");
633        std::env::set_var("PATH", joined);
634        test();
635        match original {
636            Some(path) => std::env::set_var("PATH", path),
637            None => std::env::remove_var("PATH"),
638        }
639    }
640
641    #[test]
642    fn heals_nested_yaml_environment_blocks() {
643        let yaml = r#"
644project_name: demo
645port: 3000
646build_dir: $HOME/demo
647environment:
648  production:
649    DATABASE_URL: postgres://localhost/demo
650    LOG_LEVEL: info
651services:
652  - name: api
653    target: rust
654    branch: main
655    port: 3001
656    environment:
657      production:
658        SERVICE_TOKEN: abc123
659"#;
660
661        let (config, healed_content) =
662            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
663
664        let environment = config.environment.expect("top-level env should exist");
665        assert_eq!(
666            environment.get("DATABASE_URL"),
667            Some(&"postgres://localhost/demo".to_string())
668        );
669        assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
670
671        let service_environment = config.services.expect("services should exist")[0]
672            .environment
673            .clone()
674            .expect("service env should exist");
675        assert_eq!(
676            service_environment.get("SERVICE_TOKEN"),
677            Some(&"abc123".to_string())
678        );
679        assert!(healed_content.is_some());
680    }
681
682    #[test]
683    fn heals_non_string_environment_values_in_json() {
684        let json = r#"{
685  "project_name": "demo",
686  "port": 3000,
687  "build_dir": "$HOME/demo",
688  "environment": {
689    "PORT": 3000,
690    "DEBUG": true,
691    "EMPTY": null
692  }
693}"#;
694
695        let (config, healed_content) =
696            parse_config_with_auto_heal::<XbpConfig>(json, "json").expect("config should heal");
697
698        let environment = config.environment.expect("top-level env should exist");
699        assert_eq!(environment.get("PORT"), Some(&"3000".to_string()));
700        assert_eq!(environment.get("DEBUG"), Some(&"true".to_string()));
701        assert_eq!(environment.get("EMPTY"), Some(&String::new()));
702        assert!(healed_content.is_some());
703    }
704
705    #[test]
706    fn command_helpers_respect_path_order() {
707        let bin_dir = make_temp_path("bin");
708        fs::create_dir_all(&bin_dir).expect("temp dir should be created");
709        fs::write(bin_dir.join("python"), b"").expect("python file should be created");
710        fs::write(bin_dir.join("pip"), b"").expect("pip file should be created");
711
712        with_path(std::slice::from_ref(&bin_dir), || {
713            assert!(command_exists("python"));
714            assert_eq!(
715                first_available_command(&["python3", "python"]),
716                Some("python".to_string())
717            );
718            assert_eq!(preferred_python_command(), "python".to_string());
719            assert_eq!(preferred_pip_command(), "pip".to_string());
720        });
721
722        fs::remove_dir_all(&bin_dir).expect("temp dir should be removed");
723    }
724
725    #[test]
726    fn resolves_xbp_project_for_nested_paths() {
727        let project_root = make_temp_path("ownership");
728        let service_dir = project_root.join("services").join("api");
729        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
730        fs::create_dir_all(&service_dir).expect("service dir should be created");
731        fs::write(
732            project_root.join(".xbp").join("xbp.json"),
733            br#"{"project_name":"demo"}"#,
734        )
735        .expect("xbp config should be written");
736
737        let known_projects = vec![KnownXbpProject {
738            root: project_root.clone(),
739            name: "demo".to_string(),
740        }];
741
742        assert_eq!(
743            resolve_xbp_project_for_path(&service_dir, &known_projects),
744            Some("demo".to_string())
745        );
746
747        fs::remove_dir_all(&project_root).expect("temp project should be removed");
748    }
749
750    #[test]
751    fn ignores_non_xbp_paths_for_project_resolution() {
752        let project_root = make_temp_path("ownership-miss");
753        let other_dir = make_temp_path("ownership-other");
754        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
755        fs::create_dir_all(&other_dir).expect("other dir should be created");
756        fs::write(
757            project_root.join(".xbp").join("xbp.json"),
758            br#"{"project_name":"demo"}"#,
759        )
760        .expect("xbp config should be written");
761
762        let known_projects = vec![KnownXbpProject {
763            root: project_root.clone(),
764            name: "demo".to_string(),
765        }];
766
767        assert_eq!(
768            resolve_xbp_project_for_path(&other_dir, &known_projects),
769            None
770        );
771
772        fs::remove_dir_all(&project_root).expect("temp project should be removed");
773        fs::remove_dir_all(&other_dir).expect("temp other dir should be removed");
774    }
775
776    #[test]
777    fn auto_converts_legacy_json_to_yaml() {
778        let project_root = make_temp_path("json-to-yaml");
779        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
780        let json_path = project_root.join(".xbp").join("xbp.json");
781        fs::write(
782            &json_path,
783            br#"{"project_name":"demo","port":3000,"build_dir":"$HOME/demo"}"#,
784        )
785        .expect("json should be written");
786
787        let output = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &json_path)
788            .expect("conversion should succeed")
789            .expect("yaml path should be returned");
790        assert!(output.exists(), "converted yaml should exist");
791
792        let yaml = fs::read_to_string(output).expect("yaml should be readable");
793        assert!(yaml.contains("project_name: demo"));
794
795        fs::remove_dir_all(project_root).expect("temp project should be removed");
796    }
797
798    #[test]
799    fn writes_json_from_yaml_config() {
800        let project_root = make_temp_path("yaml-to-json");
801        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
802        let yaml_path = project_root.join(".xbp").join("xbp.yaml");
803        let json_out = project_root.join("xbp.json");
804        fs::write(
805            &yaml_path,
806            "project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
807        )
808        .expect("yaml should be written");
809
810        write_json_config_from_any_xbp_config(&yaml_path, &json_out)
811            .expect("json should be generated from yaml");
812
813        let json = fs::read_to_string(json_out).expect("json should be readable");
814        assert!(json.contains("\"project_name\": \"demo\""));
815
816        fs::remove_dir_all(project_root).expect("temp project should be removed");
817    }
818}