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