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