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