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 env_files;
7pub mod process_monitor_json;
8pub mod project_paths;
9pub mod version;
10
11pub use env_files::{
12    normalize_env_value, parse_env_content, parse_env_file, resolve_env_placeholders,
13    to_env_references,
14};
15pub use process_monitor_json::{
16    fix_cursor_process_monitor_json, fix_cursor_process_monitor_json_file,
17    CursorProcessMonitorJsonFix,
18};
19pub use project_paths::{collapse_project_path, resolve_project_path};
20pub use version::{fetch_version, increment_version};
21
22use serde::de::DeserializeOwned;
23use serde_json::{Map, Value};
24use std::collections::{BTreeMap, HashMap, HashSet};
25use std::fs;
26use std::path::{Path, PathBuf};
27use std::process::Command;
28use sysinfo::{Pid, System};
29
30use crate::profile::find_all_xbp_projects;
31
32#[derive(Debug, Clone)]
33pub struct FoundXbpConfig {
34    pub project_root: PathBuf,
35    pub config_path: PathBuf,
36    pub kind: &'static str,
37    pub location: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct KnownXbpProject {
42    pub root: PathBuf,
43    pub name: String,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct ListeningPortOwnership {
48    pub pids: Vec<u32>,
49    pub xbp_projects: Vec<String>,
50}
51
52pub fn default_project_yaml_config_path(project_root: &Path) -> PathBuf {
53    project_root.join(".xbp").join("xbp.yaml")
54}
55
56pub fn find_existing_yaml_xbp_config(project_root: &Path) -> Option<PathBuf> {
57    let candidates = [
58        project_root.join(".xbp").join("xbp.yaml"),
59        project_root.join(".xbp").join("xbp.yml"),
60        project_root.join("xbp.yaml"),
61        project_root.join("xbp.yml"),
62    ];
63
64    candidates.into_iter().find(|candidate| candidate.exists())
65}
66
67pub fn maybe_auto_convert_legacy_xbp_json_to_yaml(
68    project_root: &Path,
69    config_path: &Path,
70) -> Result<Option<PathBuf>, String> {
71    if config_path.file_name() != Some(std::ffi::OsStr::new("xbp.json")) {
72        return Ok(None);
73    }
74
75    if !config_path.exists() {
76        return Ok(None);
77    }
78
79    if let Some(existing_yaml) = find_existing_yaml_xbp_config(project_root) {
80        return Ok(Some(existing_yaml));
81    }
82
83    let content = fs::read_to_string(config_path).map_err(|e| {
84        format!(
85            "Failed to read legacy JSON config {}: {}",
86            config_path.display(),
87            e
88        )
89    })?;
90    let value: Value = serde_json::from_str(&content)
91        .map_err(|e| format!("Failed to parse legacy JSON config: {}", e))?;
92
93    let yaml_path = default_project_yaml_config_path(project_root);
94    if let Some(parent) = yaml_path.parent() {
95        fs::create_dir_all(parent).map_err(|e| {
96            format!(
97                "Failed to create config directory {}: {}",
98                parent.display(),
99                e
100            )
101        })?;
102    }
103
104    let yaml = serde_yaml::to_string(&value)
105        .map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
106    fs::write(&yaml_path, yaml)
107        .map_err(|e| format!("Failed to write YAML config {}: {}", yaml_path.display(), e))?;
108
109    Ok(Some(yaml_path))
110}
111
112pub fn write_json_config_from_any_xbp_config(
113    config_path: &Path,
114    output_json_path: &Path,
115) -> Result<(), String> {
116    let content = fs::read_to_string(config_path)
117        .map_err(|e| format!("Failed to read config {}: {}", config_path.display(), e))?;
118
119    let kind = if config_path
120        .extension()
121        .and_then(|ext| ext.to_str())
122        .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
123        .unwrap_or(false)
124    {
125        "yaml"
126    } else {
127        "json"
128    };
129
130    let value = if kind == "yaml" {
131        let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content)
132            .map_err(|e| format!("Failed to parse YAML config: {}", e))?;
133        serde_json::to_value(yaml_value)
134            .map_err(|e| format!("Failed to convert YAML config to JSON value: {}", e))?
135    } else {
136        serde_json::from_str::<Value>(&content)
137            .map_err(|e| format!("Failed to parse JSON config: {}", e))?
138    };
139
140    if let Some(parent) = output_json_path.parent() {
141        fs::create_dir_all(parent).map_err(|e| {
142            format!(
143                "Failed to create config directory {}: {}",
144                parent.display(),
145                e
146            )
147        })?;
148    }
149
150    let rendered = serde_json::to_string_pretty(&value)
151        .map_err(|e| format!("Failed to serialize JSON config: {}", e))?;
152    fs::write(output_json_path, rendered).map_err(|e| {
153        format!(
154            "Failed to write JSON config {}: {}",
155            output_json_path.display(),
156            e
157        )
158    })?;
159
160    Ok(())
161}
162
163pub fn find_xbp_config_upwards(start_dir: &Path) -> Option<FoundXbpConfig> {
164    for dir in start_dir.ancestors() {
165        let candidates: [(PathBuf, &'static str); 6] = [
166            (dir.join(".xbp").join("xbp.yaml"), "yaml"),
167            (dir.join(".xbp").join("xbp.yml"), "yaml"),
168            (dir.join(".xbp").join("xbp.json"), "json"),
169            (dir.join("xbp.yaml"), "yaml"),
170            (dir.join("xbp.yml"), "yaml"),
171            (dir.join("xbp.json"), "json"),
172        ];
173
174        for (path, kind) in candidates {
175            if !path.exists() {
176                continue;
177            }
178
179            let project_root = path
180                .parent()
181                .and_then(|p| {
182                    if p.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
183                        p.parent().map(|pp| pp.to_path_buf())
184                    } else {
185                        Some(p.to_path_buf())
186                    }
187                })
188                .unwrap_or_else(|| dir.to_path_buf());
189
190            let location = path
191                .strip_prefix(&project_root)
192                .ok()
193                .map(|p| p.to_string_lossy().replace('\\', "/"))
194                .unwrap_or_else(|| path.to_string_lossy().replace('\\', "/"));
195
196            return Some(FoundXbpConfig {
197                project_root,
198                config_path: path,
199                kind,
200                location,
201            });
202        }
203    }
204
205    None
206}
207
208pub fn collect_known_xbp_projects() -> Vec<KnownXbpProject> {
209    let mut projects = Vec::new();
210    let mut seen_roots = HashSet::new();
211
212    for project in find_all_xbp_projects() {
213        let root = canonicalize_or_fallback(&project.path);
214        if seen_roots.insert(root.clone()) {
215            projects.push(KnownXbpProject {
216                root,
217                name: project.name,
218            });
219        }
220    }
221
222    if let Ok(current_dir) = std::env::current_dir() {
223        if let Some(found) = find_xbp_config_upwards(&current_dir) {
224            let root = canonicalize_or_fallback(&found.project_root);
225            if seen_roots.insert(root.clone()) {
226                let name = root
227                    .file_name()
228                    .and_then(|value| value.to_str())
229                    .unwrap_or("current")
230                    .to_string();
231                projects.push(KnownXbpProject { root, name });
232            }
233        }
234    }
235
236    projects.sort_by(|left, right| {
237        right
238            .root
239            .components()
240            .count()
241            .cmp(&left.root.components().count())
242            .then_with(|| left.name.cmp(&right.name))
243    });
244    projects
245}
246
247pub fn resolve_xbp_project_for_path(
248    candidate: &Path,
249    known_projects: &[KnownXbpProject],
250) -> Option<String> {
251    if candidate.as_os_str().is_empty() {
252        return None;
253    }
254
255    if let Some(found) = find_xbp_config_upwards(candidate) {
256        let found_root = canonicalize_or_fallback(&found.project_root);
257        if let Some(project) = known_projects
258            .iter()
259            .find(|project| canonicalize_or_fallback(&project.root) == found_root)
260        {
261            return Some(project.name.clone());
262        }
263
264        return found_root
265            .file_name()
266            .and_then(|value| value.to_str())
267            .map(|value| value.to_string());
268    }
269
270    let candidate_path = canonicalize_or_fallback(candidate);
271    known_projects
272        .iter()
273        .find(|project| candidate_path.starts_with(canonicalize_or_fallback(&project.root)))
274        .map(|project| project.name.clone())
275}
276
277pub fn collect_listening_port_ownership() -> Result<BTreeMap<u16, ListeningPortOwnership>, String> {
278    use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
279
280    let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
281    let proto_flags = ProtocolFlags::TCP;
282    let sockets = get_sockets_info(af_flags, proto_flags)
283        .map_err(|e| format!("Failed to get sockets info: {}", e))?;
284
285    let mut system = System::new_all();
286    system.refresh_all();
287    let known_projects = collect_known_xbp_projects();
288    let mut pid_project_cache: HashMap<u32, Option<String>> = HashMap::new();
289    let mut ports: BTreeMap<u16, ListeningPortOwnership> = BTreeMap::new();
290
291    for socket in sockets {
292        if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
293            let state = format!("{:?}", tcp.state);
294            if state != "Listen" && state != "LISTEN" {
295                continue;
296            }
297
298            let row = ports.entry(tcp.local_port).or_default();
299            for pid in socket.associated_pids {
300                row.pids.push(pid);
301                if let Some(project) = pid_project_cache
302                    .entry(pid)
303                    .or_insert_with(|| resolve_xbp_project_for_pid(pid, &system, &known_projects))
304                    .clone()
305                {
306                    row.xbp_projects.push(project);
307                }
308            }
309        }
310    }
311
312    for row in ports.values_mut() {
313        row.pids.sort_unstable();
314        row.pids.dedup();
315        row.xbp_projects.sort();
316        row.xbp_projects.dedup();
317    }
318
319    Ok(ports)
320}
321
322fn resolve_xbp_project_for_pid(
323    pid: u32,
324    system: &System,
325    known_projects: &[KnownXbpProject],
326) -> Option<String> {
327    let process = system.process(Pid::from_u32(pid))?;
328
329    if let Some(project) = process
330        .cwd()
331        .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
332    {
333        return Some(project);
334    }
335
336    process
337        .exe()
338        .and_then(|path| path.parent())
339        .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
340}
341
342fn canonicalize_or_fallback(path: &Path) -> PathBuf {
343    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
344}
345
346pub fn expand_home_in_string(input: &str) -> String {
347    let home = dirs::home_dir()
348        .unwrap_or_else(|| std::path::PathBuf::from("."))
349        .to_string_lossy()
350        .to_string();
351
352    if input == "~" {
353        return home;
354    }
355
356    if let Some(rest) = input
357        .strip_prefix("~/")
358        .or_else(|| input.strip_prefix("~\\"))
359    {
360        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
361    }
362
363    if let Some(rest) = input
364        .strip_prefix("$HOME/")
365        .or_else(|| input.strip_prefix("$HOME\\"))
366    {
367        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
368    }
369
370    if let Some(rest) = input
371        .strip_prefix("${HOME}/")
372        .or_else(|| input.strip_prefix("${HOME}\\"))
373    {
374        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
375    }
376
377    input.to_string()
378}
379
380pub fn collapse_home_to_env(input: &str) -> String {
381    let home = dirs::home_dir()
382        .unwrap_or_else(|| std::path::PathBuf::from("."))
383        .to_string_lossy()
384        .to_string();
385
386    if input == home {
387        return "$HOME".to_string();
388    }
389
390    if let Some(rest) = input.strip_prefix(&(home.clone() + "/")) {
391        return format!("$HOME/{}", rest);
392    }
393
394    if let Some(rest) = input.strip_prefix(&(home.clone() + "\\")) {
395        return format!("$HOME\\{}", rest);
396    }
397
398    input.to_string()
399}
400
401pub fn command_exists(program: &str) -> bool {
402    let Some(path_var) = std::env::var_os("PATH") else {
403        return false;
404    };
405
406    for dir in std::env::split_paths(&path_var) {
407        let candidate = dir.join(program);
408        if candidate.is_file() {
409            return true;
410        }
411
412        #[cfg(windows)]
413        for ext in ["exe", "cmd", "bat"] {
414            let candidate = dir.join(format!("{}.{}", program, ext));
415            if candidate.is_file() {
416                return true;
417            }
418        }
419    }
420
421    false
422}
423
424pub fn git_remote_url_from_metadata(
425    project_root: &Path,
426    remote: &str,
427) -> Result<Option<String>, String> {
428    let Some(git_dir) = resolve_git_dir(project_root)? else {
429        return Ok(None);
430    };
431
432    let config_path = git_dir.join("config");
433    if !config_path.exists() {
434        return Ok(None);
435    }
436
437    let content = fs::read_to_string(&config_path)
438        .map_err(|e| format!("Failed to read git config {}: {}", config_path.display(), e))?;
439
440    Ok(parse_git_remote_url_from_config(&content, remote))
441}
442
443pub fn parse_github_repo_from_remote_url(url: &str) -> Option<(String, String)> {
444    let normalized: &str = url.trim();
445
446    let repo_path: String = if let Some(path) = normalized.strip_prefix("git@github.com:") {
447        path.to_string()
448    } else if let Some(path) = parse_github_https_repo_path(normalized) {
449        path
450    } else if let Some(path) = normalized.strip_prefix("ssh://git@github.com/") {
451        path.to_string()
452    } else {
453        return None;
454    };
455
456    let cleaned: &str = repo_path.trim_end_matches('/').trim_end_matches(".git");
457    let mut segments: std::str::Split<'_, char> = cleaned.split('/');
458    let owner: &str = segments.next()?.trim();
459    let repo: &str = segments.next()?.trim();
460    if owner.is_empty() || repo.is_empty() || segments.next().is_some() {
461        return None;
462    }
463
464    Some((owner.to_string(), repo.to_string()))
465}
466
467pub fn redact_remote_url_credentials(url: &str) -> String {
468    if !url.contains('@') || !url.contains("://") {
469        return url.to_string();
470    }
471    let mut parsed: reqwest::Url = match reqwest::Url::parse(url) {
472        Ok(value) => value,
473        Err(_) => return url.to_string(),
474    };
475    if parsed.password().is_some() {
476        let _ = parsed.set_password(Some("REDACTED"));
477    }
478    if !parsed.username().is_empty() {
479        let _ = parsed.set_username("REDACTED");
480    }
481    parsed.to_string()
482}
483
484fn resolve_git_dir(project_root: &Path) -> Result<Option<PathBuf>, String> {
485    let dot_git = project_root.join(".git");
486    if dot_git.is_dir() {
487        return Ok(Some(dot_git));
488    }
489
490    if !dot_git.exists() {
491        return Ok(None);
492    }
493
494    let content = fs::read_to_string(&dot_git)
495        .map_err(|e| format!("Failed to read git metadata {}: {}", dot_git.display(), e))?;
496    let git_dir = content
497        .lines()
498        .find_map(|line| line.trim().strip_prefix("gitdir:").map(str::trim))
499        .filter(|value| !value.is_empty())
500        .ok_or_else(|| format!("Failed to parse gitdir pointer from {}", dot_git.display()))?;
501
502    let git_dir_path = PathBuf::from(git_dir);
503    let resolved = if git_dir_path.is_absolute() {
504        git_dir_path
505    } else {
506        dot_git.parent().unwrap_or(project_root).join(git_dir_path)
507    };
508
509    Ok(Some(resolved))
510}
511
512fn parse_git_remote_url_from_config(content: &str, remote: &str) -> Option<String> {
513    let expected_quoted = format!(r#"remote "{}""#, remote);
514    let expected_dotted = format!("remote.{}", remote);
515    let mut in_target_section = false;
516
517    for line in content.lines() {
518        let trimmed = line.trim();
519        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
520            continue;
521        }
522
523        if trimmed.starts_with('[') && trimmed.ends_with(']') {
524            let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
525            in_target_section = section.eq_ignore_ascii_case(&expected_quoted)
526                || section.eq_ignore_ascii_case(&expected_dotted);
527            continue;
528        }
529
530        if !in_target_section {
531            continue;
532        }
533
534        let Some((key, value)) = trimmed.split_once('=') else {
535            continue;
536        };
537        if key.trim().eq_ignore_ascii_case("url") {
538            let value = value.trim();
539            if !value.is_empty() {
540                return Some(value.to_string());
541            }
542        }
543    }
544
545    None
546}
547
548fn parse_github_https_repo_path(url: &str) -> Option<String> {
549    let parsed: reqwest::Url = reqwest::Url::parse(url).ok()?;
550    if !matches!(parsed.scheme(), "http" | "https") {
551        return None;
552    }
553    if parsed.host_str()?.eq_ignore_ascii_case("github.com") {
554        return Some(parsed.path().trim_start_matches('/').to_string());
555    }
556    None
557}
558
559pub fn first_available_command(candidates: &[&str]) -> Option<String> {
560    candidates
561        .iter()
562        .find(|candidate| command_exists(candidate))
563        .map(|candidate| (*candidate).to_string())
564}
565
566pub fn preferred_python_command() -> String {
567    first_available_command(&["python3", "python"]).unwrap_or_else(|| {
568        if cfg!(target_os = "windows") {
569            "python".to_string()
570        } else {
571            "python3".to_string()
572        }
573    })
574}
575
576pub fn preferred_pip_command() -> String {
577    first_available_command(&["pip3", "pip"]).unwrap_or_else(|| {
578        if cfg!(target_os = "windows") {
579            "pip".to_string()
580        } else {
581            "pip3".to_string()
582        }
583    })
584}
585
586pub fn open_with_default_handler(target: &str) -> Result<(), String> {
587    let mut command = if cfg!(target_os = "windows") {
588        let mut cmd = Command::new("cmd");
589        cmd.arg("/C").arg("start").arg("").arg(target);
590        cmd
591    } else if cfg!(target_os = "macos") {
592        let mut cmd = Command::new("open");
593        cmd.arg(target);
594        cmd
595    } else {
596        let mut cmd = Command::new("xdg-open");
597        cmd.arg(target);
598        cmd
599    };
600
601    command
602        .spawn()
603        .map_err(|e| format!("Failed to open '{}': {}", target, e))?;
604    Ok(())
605}
606
607pub fn open_path_with_editor(path: &Path) -> Result<(), String> {
608    if let Ok(editor) = std::env::var("EDITOR") {
609        let mut parts = editor.split_whitespace();
610        let binary = parts
611            .next()
612            .ok_or_else(|| "EDITOR is set but empty".to_string())?;
613        let mut command = Command::new(binary);
614        for part in parts {
615            command.arg(part);
616        }
617        command
618            .arg(path)
619            .spawn()
620            .map_err(|e| format!("Failed to launch editor '{}': {}", editor, e))?;
621        return Ok(());
622    }
623
624    open_with_default_handler(&path.display().to_string())
625}
626
627pub fn parse_config_with_auto_heal<T: DeserializeOwned>(
628    content: &str,
629    kind: &str,
630) -> Result<(T, Option<String>), String> {
631    let mut value = match kind {
632        "yaml" => {
633            let yaml_value: serde_yaml::Value =
634                serde_yaml::from_str(content).map_err(|e| e.to_string())?;
635            serde_json::to_value(yaml_value).map_err(|e| e.to_string())?
636        }
637        "json" => serde_json::from_str::<Value>(content).map_err(|e| e.to_string())?,
638        _ => return Err(format!("Unsupported config kind: {}", kind)),
639    };
640
641    let healed = auto_heal_xbp_config_value(&mut value);
642    let parsed = serde_json::from_value::<T>(value.clone()).map_err(|e| e.to_string())?;
643
644    let healed_content = if healed {
645        Some(match kind {
646            "yaml" => serde_yaml::to_string(&value).map_err(|e| e.to_string())?,
647            "json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
648            _ => unreachable!(),
649        })
650    } else {
651        None
652    };
653
654    Ok((parsed, healed_content))
655}
656
657pub fn heal_config_file(path: &Path, kind: &str) -> Result<bool, String> {
658    let content = fs::read_to_string(path)
659        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
660    let (_, healed_content) = parse_config_with_auto_heal::<Value>(&content, kind)?;
661
662    if let Some(healed_content) = healed_content {
663        fs::write(path, healed_content)
664            .map_err(|e| format!("Failed to write healed config {}: {}", path.display(), e))?;
665        return Ok(true);
666    }
667
668    Ok(false)
669}
670
671fn auto_heal_xbp_config_value(value: &mut Value) -> bool {
672    let Some(root) = value.as_object_mut() else {
673        return false;
674    };
675
676    let mut changed = false;
677
678    if let Some(environment) = root.get_mut("environment") {
679        changed |= normalize_environment_value(environment);
680    }
681
682    if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
683        for service in services {
684            if let Some(environment) = service
685                .as_object_mut()
686                .and_then(|service| service.get_mut("environment"))
687            {
688                changed |= normalize_environment_value(environment);
689            }
690        }
691    }
692
693    changed
694}
695
696fn normalize_environment_value(value: &mut Value) -> bool {
697    let Value::Object(map) = value else {
698        return false;
699    };
700
701    let original = map.clone();
702    let mut normalized = Map::new();
703    let mut changed = false;
704
705    flatten_environment_entries(&original, &mut normalized, &mut changed);
706
707    if normalized != original {
708        *map = normalized;
709        changed = true;
710    }
711
712    changed
713}
714
715fn flatten_environment_entries(
716    source: &Map<String, Value>,
717    target: &mut Map<String, Value>,
718    changed: &mut bool,
719) {
720    for (key, value) in source {
721        match value {
722            Value::String(string) => {
723                target.insert(key.clone(), Value::String(string.clone()));
724            }
725            Value::Number(number) => {
726                *changed = true;
727                target.insert(key.clone(), Value::String(number.to_string()));
728            }
729            Value::Bool(boolean) => {
730                *changed = true;
731                target.insert(key.clone(), Value::String(boolean.to_string()));
732            }
733            Value::Null => {
734                *changed = true;
735                target.insert(key.clone(), Value::String(String::new()));
736            }
737            Value::Array(items) => {
738                *changed = true;
739                let serialized = serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string());
740                target.insert(key.clone(), Value::String(serialized));
741            }
742            Value::Object(nested) => {
743                *changed = true;
744                flatten_environment_entries(nested, target, changed);
745            }
746        }
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use super::{
753        command_exists, first_available_command, git_remote_url_from_metadata,
754        maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal,
755        parse_github_repo_from_remote_url, preferred_pip_command, preferred_python_command,
756        redact_remote_url_credentials, resolve_xbp_project_for_path,
757        write_json_config_from_any_xbp_config, KnownXbpProject,
758    };
759    use crate::strategies::XbpConfig;
760    use std::fs;
761    use std::path::PathBuf;
762    use std::sync::{Mutex, OnceLock};
763
764    fn path_lock() -> &'static Mutex<()> {
765        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
766        LOCK.get_or_init(|| Mutex::new(()))
767    }
768
769    fn make_temp_path(name: &str) -> PathBuf {
770        let mut path = std::env::temp_dir();
771        path.push(format!("xbp-test-{}-{}", name, std::process::id()));
772        path
773    }
774
775    fn with_path<F>(entries: &[PathBuf], test: F)
776    where
777        F: FnOnce(),
778    {
779        let _guard = path_lock().lock().expect("path lock should be available");
780        let original = std::env::var_os("PATH");
781        let joined = std::env::join_paths(entries).expect("PATH entries should join");
782        std::env::set_var("PATH", joined);
783        test();
784        match original {
785            Some(path) => std::env::set_var("PATH", path),
786            None => std::env::remove_var("PATH"),
787        }
788    }
789
790    #[test]
791    fn heals_nested_yaml_environment_blocks() {
792        let yaml = r#"
793project_name: demo
794port: 3000
795build_dir: $HOME/demo
796environment:
797  production:
798    DATABASE_URL: postgres://localhost/demo
799    LOG_LEVEL: info
800services:
801  - name: api
802    target: rust
803    branch: main
804    port: 3001
805    environment:
806      production:
807        SERVICE_TOKEN: abc123
808"#;
809
810        let (config, healed_content) =
811            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
812
813        let environment = config.environment.expect("top-level env should exist");
814        assert_eq!(
815            environment.get("DATABASE_URL"),
816            Some(&"postgres://localhost/demo".to_string())
817        );
818        assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
819
820        let service_environment = config.services.expect("services should exist")[0]
821            .environment
822            .clone()
823            .expect("service env should exist");
824        assert_eq!(
825            service_environment.get("SERVICE_TOKEN"),
826            Some(&"abc123".to_string())
827        );
828        assert!(healed_content.is_some());
829    }
830
831    #[test]
832    fn heals_non_string_environment_values_in_json() {
833        let json = r#"{
834  "project_name": "demo",
835  "port": 3000,
836  "build_dir": "$HOME/demo",
837  "environment": {
838    "PORT": 3000,
839    "DEBUG": true,
840    "EMPTY": null
841  }
842}"#;
843
844        let (config, healed_content) =
845            parse_config_with_auto_heal::<XbpConfig>(json, "json").expect("config should heal");
846
847        let environment = config.environment.expect("top-level env should exist");
848        assert_eq!(environment.get("PORT"), Some(&"3000".to_string()));
849        assert_eq!(environment.get("DEBUG"), Some(&"true".to_string()));
850        assert_eq!(environment.get("EMPTY"), Some(&String::new()));
851        assert!(healed_content.is_some());
852    }
853
854    #[test]
855    fn command_helpers_respect_path_order() {
856        let bin_dir = make_temp_path("bin");
857        fs::create_dir_all(&bin_dir).expect("temp dir should be created");
858        fs::write(bin_dir.join("python"), b"").expect("python file should be created");
859        fs::write(bin_dir.join("pip"), b"").expect("pip file should be created");
860
861        with_path(std::slice::from_ref(&bin_dir), || {
862            assert!(command_exists("python"));
863            assert_eq!(
864                first_available_command(&["python3", "python"]),
865                Some("python".to_string())
866            );
867            assert_eq!(preferred_python_command(), "python".to_string());
868            assert_eq!(preferred_pip_command(), "pip".to_string());
869        });
870
871        fs::remove_dir_all(&bin_dir).expect("temp dir should be removed");
872    }
873
874    #[test]
875    fn resolves_xbp_project_for_nested_paths() {
876        let project_root = make_temp_path("ownership");
877        let service_dir = project_root.join("services").join("api");
878        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
879        fs::create_dir_all(&service_dir).expect("service dir should be created");
880        fs::write(
881            project_root.join(".xbp").join("xbp.json"),
882            br#"{"project_name":"demo"}"#,
883        )
884        .expect("xbp config should be written");
885
886        let known_projects = vec![KnownXbpProject {
887            root: project_root.clone(),
888            name: "demo".to_string(),
889        }];
890
891        assert_eq!(
892            resolve_xbp_project_for_path(&service_dir, &known_projects),
893            Some("demo".to_string())
894        );
895
896        fs::remove_dir_all(&project_root).expect("temp project should be removed");
897    }
898
899    #[test]
900    fn ignores_non_xbp_paths_for_project_resolution() {
901        let project_root = make_temp_path("ownership-miss");
902        let other_dir = make_temp_path("ownership-other");
903        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
904        fs::create_dir_all(&other_dir).expect("other dir should be created");
905        fs::write(
906            project_root.join(".xbp").join("xbp.json"),
907            br#"{"project_name":"demo"}"#,
908        )
909        .expect("xbp config should be written");
910
911        let known_projects = vec![KnownXbpProject {
912            root: project_root.clone(),
913            name: "demo".to_string(),
914        }];
915
916        assert_eq!(
917            resolve_xbp_project_for_path(&other_dir, &known_projects),
918            None
919        );
920
921        fs::remove_dir_all(&project_root).expect("temp project should be removed");
922        fs::remove_dir_all(&other_dir).expect("temp other dir should be removed");
923    }
924
925    #[test]
926    fn auto_converts_legacy_json_to_yaml() {
927        let project_root = make_temp_path("json-to-yaml");
928        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
929        let json_path = project_root.join(".xbp").join("xbp.json");
930        fs::write(
931            &json_path,
932            br#"{"project_name":"demo","port":3000,"build_dir":"$HOME/demo"}"#,
933        )
934        .expect("json should be written");
935
936        let output = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &json_path)
937            .expect("conversion should succeed")
938            .expect("yaml path should be returned");
939        assert!(output.exists(), "converted yaml should exist");
940
941        let yaml = fs::read_to_string(output).expect("yaml should be readable");
942        assert!(yaml.contains("project_name: demo"));
943
944        fs::remove_dir_all(project_root).expect("temp project should be removed");
945    }
946
947    #[test]
948    fn writes_json_from_yaml_config() {
949        let project_root = make_temp_path("yaml-to-json");
950        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
951        let yaml_path = project_root.join(".xbp").join("xbp.yaml");
952        let json_out = project_root.join("xbp.json");
953        fs::write(
954            &yaml_path,
955            "project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
956        )
957        .expect("yaml should be written");
958
959        write_json_config_from_any_xbp_config(&yaml_path, &json_out)
960            .expect("json should be generated from yaml");
961
962        let json = fs::read_to_string(json_out).expect("json should be readable");
963        assert!(json.contains("\"project_name\": \"demo\""));
964
965        fs::remove_dir_all(project_root).expect("temp project should be removed");
966    }
967
968    #[test]
969    fn parses_github_repo_from_supported_remote_urls() {
970        assert_eq!(
971            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
972            Some(("xylex-group".to_string(), "xbp".to_string()))
973        );
974        assert_eq!(
975            parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
976            Some(("xylex-group".to_string(), "xbp".to_string()))
977        );
978        assert_eq!(
979            parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
980            Some(("xylex-group".to_string(), "xbp".to_string()))
981        );
982        assert_eq!(
983            parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
984            None
985        );
986        assert_eq!(
987            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp/extra.git"),
988            None
989        );
990    }
991
992    #[test]
993    fn redacts_credentials_in_remote_urls() {
994        let redacted = redact_remote_url_credentials("https://token@example.com/xylex-group/xbp");
995        assert!(redacted.contains("REDACTED"));
996        assert!(!redacted.contains("token@example.com"));
997    }
998
999    #[test]
1000    fn reads_origin_remote_url_from_git_config_directory() {
1001        let project_root = make_temp_path("git-config-dir");
1002        let git_dir = project_root.join(".git");
1003        fs::create_dir_all(&git_dir).expect("git dir should be created");
1004        fs::write(
1005            git_dir.join("config"),
1006            "[remote \"origin\"]\n\turl = https://github.com/xylex-group/xbp.git\n",
1007        )
1008        .expect("git config should be written");
1009
1010        let remote = git_remote_url_from_metadata(&project_root, "origin")
1011            .expect("git metadata should parse")
1012            .expect("origin remote should exist");
1013        assert_eq!(remote, "https://github.com/xylex-group/xbp.git");
1014
1015        fs::remove_dir_all(project_root).expect("temp project should be removed");
1016    }
1017
1018    #[test]
1019    fn reads_origin_remote_url_from_gitdir_pointer() {
1020        let project_root = make_temp_path("git-config-file");
1021        let nested_git_dir = project_root.join(".git-real");
1022        fs::create_dir_all(&nested_git_dir).expect("gitdir target should be created");
1023        fs::write(project_root.join(".git"), "gitdir: .git-real\n")
1024            .expect("gitdir pointer should be written");
1025        fs::write(
1026            nested_git_dir.join("config"),
1027            "[remote \"origin\"]\n\turl = git@github.com:xylex-group/xbp.git\n",
1028        )
1029        .expect("git config should be written");
1030
1031        let remote = git_remote_url_from_metadata(&project_root, "origin")
1032            .expect("git metadata should parse")
1033            .expect("origin remote should exist");
1034        assert_eq!(remote, "git@github.com:xylex-group/xbp.git");
1035
1036        fs::remove_dir_all(project_root).expect("temp project should be removed");
1037    }
1038}