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 pnpm_package_json;
10pub mod process_monitor_json;
11pub mod project_paths;
12pub mod version;
13pub mod xbp_ignore;
14
15pub use cargo_manifest::{
16    resolve_cargo_package_version, resolve_cargo_package_version_required,
17    write_cargo_package_version,
18};
19pub use env_files::{
20    first_lookup_value, load_env_lookup, normalize_env_key, normalize_env_value, parse_env_content,
21    parse_env_file, resolve_env_placeholders, strip_utf8_bom, to_env_references,
22    CLOUDFLARE_ACCOUNT_ID_ENV_KEYS,
23};
24pub use node_toolchain::{
25    find_node_toolchain_root, is_node_toolchain_command, node_toolchain_wrapper_path,
26};
27pub use process_monitor_json::{
28    fix_cursor_process_monitor_json, fix_cursor_process_monitor_json_file,
29    CursorProcessMonitorJsonFix,
30};
31pub use pnpm_package_json::{migrate_package_json_pnpm_settings, PnpmSettingsMigration};
32pub use project_paths::{
33    collapse_project_path, resolve_project_dir, resolve_project_path, resolve_service_root,
34};
35pub use version::{fetch_version, increment_version};
36pub use xbp_ignore::{
37    default_xbp_ignore_path, load_project_xbp_ignore, xbp_ignore_file_candidates, XbpIgnoreSet,
38    DEFAULT_DISCOVERY_SKIP_DIRS,
39};
40
41use serde::de::DeserializeOwned;
42use serde::{Deserialize, Serialize};
43use serde_json::{Map, Value};
44use std::collections::{BTreeMap, HashMap, HashSet};
45use std::fs;
46use std::path::{Path, PathBuf};
47use std::process::Command;
48use sysinfo::{Pid, System};
49
50use crate::profile::find_all_xbp_projects;
51
52#[derive(Debug, Clone)]
53pub struct FoundXbpConfig {
54    pub project_root: PathBuf,
55    pub config_path: PathBuf,
56    pub kind: &'static str,
57    pub location: String,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct KnownXbpProject {
62    pub root: PathBuf,
63    pub name: String,
64}
65
66#[derive(Debug, Clone, Default, PartialEq, Eq)]
67pub struct ListeningPortOwnership {
68    pub pids: Vec<u32>,
69    pub xbp_projects: Vec<String>,
70}
71
72pub fn default_project_yaml_config_path(project_root: &Path) -> PathBuf {
73    project_root.join(".xbp").join("xbp.yaml")
74}
75
76pub fn find_existing_yaml_xbp_config(project_root: &Path) -> Option<PathBuf> {
77    let candidates = [
78        project_root.join(".xbp").join("xbp.yaml"),
79        project_root.join(".xbp").join("xbp.yml"),
80        project_root.join("xbp.yaml"),
81        project_root.join("xbp.yml"),
82    ];
83
84    candidates.into_iter().find(|candidate| candidate.exists())
85}
86
87pub fn maybe_auto_convert_legacy_xbp_json_to_yaml(
88    project_root: &Path,
89    config_path: &Path,
90) -> Result<Option<PathBuf>, String> {
91    if config_path.file_name() != Some(std::ffi::OsStr::new("xbp.json")) {
92        return Ok(None);
93    }
94
95    if !config_path.exists() {
96        return Ok(None);
97    }
98
99    if let Some(existing_yaml) = find_existing_yaml_xbp_config(project_root) {
100        return Ok(Some(existing_yaml));
101    }
102
103    let content = fs::read_to_string(config_path).map_err(|e| {
104        format!(
105            "Failed to read legacy JSON config {}: {}",
106            config_path.display(),
107            e
108        )
109    })?;
110    let value: Value = serde_json::from_str(&content)
111        .map_err(|e| format!("Failed to parse legacy JSON config: {}", e))?;
112
113    let yaml_path = default_project_yaml_config_path(project_root);
114    if let Some(parent) = yaml_path.parent() {
115        fs::create_dir_all(parent).map_err(|e| {
116            format!(
117                "Failed to create config directory {}: {}",
118                parent.display(),
119                e
120            )
121        })?;
122    }
123
124    let yaml = serialize_xbp_yaml(&value)?;
125    fs::write(&yaml_path, yaml)
126        .map_err(|e| format!("Failed to write YAML config {}: {}", yaml_path.display(), e))?;
127
128    Ok(Some(yaml_path))
129}
130
131pub fn serialize_xbp_yaml<T: Serialize>(value: &T) -> Result<String, String> {
132    let yaml = serde_yaml::to_string(value)
133        .map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
134    Ok(normalize_xbp_yaml_environment_quotes(&yaml))
135}
136
137fn normalize_xbp_yaml_environment_quotes(yaml: &str) -> String {
138    let mut rendered = String::with_capacity(yaml.len());
139    let mut environment_indent: Option<usize> = None;
140
141    for raw_line in yaml.split_inclusive('\n') {
142        let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
143        let newline = if raw_line.ends_with('\n') { "\n" } else { "" };
144        let indent = line
145            .chars()
146            .take_while(|character| *character == ' ')
147            .count();
148
149        if let Some(active_indent) = environment_indent {
150            if !line.trim().is_empty() && indent <= active_indent {
151                environment_indent = None;
152            }
153        }
154
155        if line.trim() == "environment:" {
156            environment_indent = Some(indent);
157            rendered.push_str(line);
158            rendered.push_str(newline);
159            continue;
160        }
161
162        if let Some(active_indent) = environment_indent {
163            if !line.trim().is_empty() && indent > active_indent {
164                if let Some(rewritten) = rewrite_environment_scalar_line(line) {
165                    rendered.push_str(&rewritten);
166                    rendered.push_str(newline);
167                    continue;
168                }
169            }
170        }
171
172        rendered.push_str(line);
173        rendered.push_str(newline);
174    }
175
176    rendered
177}
178
179fn rewrite_environment_scalar_line(line: &str) -> Option<String> {
180    let (prefix, raw_value) = line.split_once(':')?;
181    let value_fragment = raw_value.trim_start();
182    if value_fragment.is_empty() {
183        return Some(format!("{prefix}: \"\""));
184    }
185
186    let parsed =
187        serde_yaml::from_str::<serde_yaml::Value>(&format!("value: {value_fragment}\n")).ok()?;
188    let value = match parsed {
189        serde_yaml::Value::Mapping(mut mapping) => {
190            mapping.remove(serde_yaml::Value::String("value".to_string()))?
191        }
192        _ => return None,
193    };
194
195    let text = match value {
196        serde_yaml::Value::String(text) => text,
197        serde_yaml::Value::Bool(value) => value.to_string(),
198        serde_yaml::Value::Number(value) => value.to_string(),
199        serde_yaml::Value::Null => String::new(),
200        _ => return None,
201    };
202
203    Some(format!(
204        "{prefix}: {}",
205        render_yaml_environment_scalar(&text)
206    ))
207}
208
209fn render_yaml_environment_scalar(value: &str) -> String {
210    if value.is_empty() {
211        return "\"\"".to_string();
212    }
213
214    let contains_control = value
215        .chars()
216        .any(|character| matches!(character, '\n' | '\r' | '\t') || character.is_control());
217
218    if !contains_control && value.contains('"') {
219        let escaped = value.replace('\'', "''");
220        format!("'{escaped}'")
221    } else {
222        format!("\"{}\"", escape_yaml_double_quoted(value))
223    }
224}
225
226fn escape_yaml_double_quoted(value: &str) -> String {
227    let mut escaped = String::with_capacity(value.len());
228
229    for character in value.chars() {
230        match character {
231            '\\' => escaped.push_str("\\\\"),
232            '"' => escaped.push_str("\\\""),
233            '\n' => escaped.push_str("\\n"),
234            '\r' => escaped.push_str("\\r"),
235            '\t' => escaped.push_str("\\t"),
236            character if character.is_control() => {
237                use std::fmt::Write as _;
238                let _ = write!(&mut escaped, "\\u{:04x}", character as u32);
239            }
240            character => escaped.push(character),
241        }
242    }
243
244    escaped
245}
246
247pub fn write_json_config_from_any_xbp_config(
248    config_path: &Path,
249    output_json_path: &Path,
250) -> Result<(), String> {
251    let content = fs::read_to_string(config_path)
252        .map_err(|e| format!("Failed to read config {}: {}", config_path.display(), e))?;
253
254    let kind = if config_path
255        .extension()
256        .and_then(|ext| ext.to_str())
257        .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
258        .unwrap_or(false)
259    {
260        "yaml"
261    } else {
262        "json"
263    };
264
265    let value = if kind == "yaml" {
266        let (yaml_value, _) = parse_yaml_config_documents(&content)
267            .map_err(|e| format!("Failed to parse YAML config: {}", e))?;
268        serde_json::to_value(yaml_value)
269            .map_err(|e| format!("Failed to convert YAML config to JSON value: {}", e))?
270    } else {
271        let stripped = strip_utf8_bom(&content);
272        serde_json::from_str::<Value>(stripped)
273            .map_err(|e| format!("Failed to parse JSON config: {}", e))?
274    };
275
276    if let Some(parent) = output_json_path.parent() {
277        fs::create_dir_all(parent).map_err(|e| {
278            format!(
279                "Failed to create config directory {}: {}",
280                parent.display(),
281                e
282            )
283        })?;
284    }
285
286    let rendered = serde_json::to_string_pretty(&value)
287        .map_err(|e| format!("Failed to serialize JSON config: {}", e))?;
288    fs::write(output_json_path, rendered).map_err(|e| {
289        format!(
290            "Failed to write JSON config {}: {}",
291            output_json_path.display(),
292            e
293        )
294    })?;
295
296    Ok(())
297}
298
299pub fn find_xbp_config_upwards(start_dir: &Path) -> Option<FoundXbpConfig> {
300    for dir in start_dir.ancestors() {
301        let candidates: [(PathBuf, &'static str); 6] = [
302            (dir.join(".xbp").join("xbp.yaml"), "yaml"),
303            (dir.join(".xbp").join("xbp.yml"), "yaml"),
304            (dir.join(".xbp").join("xbp.json"), "json"),
305            (dir.join("xbp.yaml"), "yaml"),
306            (dir.join("xbp.yml"), "yaml"),
307            (dir.join("xbp.json"), "json"),
308        ];
309
310        for (path, kind) in candidates {
311            if !path.exists() {
312                continue;
313            }
314
315            let project_root = path
316                .parent()
317                .and_then(|p| {
318                    if p.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
319                        p.parent().map(|pp| pp.to_path_buf())
320                    } else {
321                        Some(p.to_path_buf())
322                    }
323                })
324                .unwrap_or_else(|| dir.to_path_buf());
325
326            let location = path
327                .strip_prefix(&project_root)
328                .ok()
329                .map(|p| p.to_string_lossy().replace('\\', "/"))
330                .unwrap_or_else(|| path.to_string_lossy().replace('\\', "/"));
331
332            return Some(FoundXbpConfig {
333                project_root,
334                config_path: path,
335                kind,
336                location,
337            });
338        }
339    }
340
341    None
342}
343
344pub fn collect_known_xbp_projects() -> Vec<KnownXbpProject> {
345    let mut projects = Vec::new();
346    let mut seen_roots = HashSet::new();
347
348    for project in find_all_xbp_projects() {
349        let root = canonicalize_or_fallback(&project.path);
350        if seen_roots.insert(root.clone()) {
351            projects.push(KnownXbpProject {
352                root,
353                name: project.name,
354            });
355        }
356    }
357
358    if let Ok(current_dir) = std::env::current_dir() {
359        if let Some(found) = find_xbp_config_upwards(&current_dir) {
360            let root = canonicalize_or_fallback(&found.project_root);
361            if seen_roots.insert(root.clone()) {
362                let name = root
363                    .file_name()
364                    .and_then(|value| value.to_str())
365                    .unwrap_or("current")
366                    .to_string();
367                projects.push(KnownXbpProject { root, name });
368            }
369        }
370    }
371
372    projects.sort_by(|left, right| {
373        right
374            .root
375            .components()
376            .count()
377            .cmp(&left.root.components().count())
378            .then_with(|| left.name.cmp(&right.name))
379    });
380    projects
381}
382
383pub fn resolve_xbp_project_for_path(
384    candidate: &Path,
385    known_projects: &[KnownXbpProject],
386) -> Option<String> {
387    if candidate.as_os_str().is_empty() {
388        return None;
389    }
390
391    if let Some(found) = find_xbp_config_upwards(candidate) {
392        let found_root = canonicalize_or_fallback(&found.project_root);
393        if let Some(project) = known_projects
394            .iter()
395            .find(|project| canonicalize_or_fallback(&project.root) == found_root)
396        {
397            return Some(project.name.clone());
398        }
399
400        if known_projects.is_empty() {
401            return found_root
402                .file_name()
403                .and_then(|value| value.to_str())
404                .map(|value| value.to_string());
405        }
406    }
407
408    let candidate_path = canonicalize_or_fallback(candidate);
409    known_projects
410        .iter()
411        .find(|project| candidate_path.starts_with(canonicalize_or_fallback(&project.root)))
412        .map(|project| project.name.clone())
413}
414
415pub fn collect_listening_port_ownership() -> Result<BTreeMap<u16, ListeningPortOwnership>, String> {
416    use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
417
418    let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
419    let proto_flags = ProtocolFlags::TCP;
420    let sockets = get_sockets_info(af_flags, proto_flags)
421        .map_err(|e| format!("Failed to get sockets info: {}", e))?;
422
423    let mut system = System::new_all();
424    system.refresh_all();
425    let known_projects = collect_known_xbp_projects();
426    let mut pid_project_cache: HashMap<u32, Option<String>> = HashMap::new();
427    let mut ports: BTreeMap<u16, ListeningPortOwnership> = BTreeMap::new();
428
429    for socket in sockets {
430        if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
431            let state = format!("{:?}", tcp.state);
432            if state != "Listen" && state != "LISTEN" {
433                continue;
434            }
435
436            let row = ports.entry(tcp.local_port).or_default();
437            for pid in socket.associated_pids {
438                row.pids.push(pid);
439                if let Some(project) = pid_project_cache
440                    .entry(pid)
441                    .or_insert_with(|| resolve_xbp_project_for_pid(pid, &system, &known_projects))
442                    .clone()
443                {
444                    row.xbp_projects.push(project);
445                }
446            }
447        }
448    }
449
450    for row in ports.values_mut() {
451        row.pids.sort_unstable();
452        row.pids.dedup();
453        row.xbp_projects.sort();
454        row.xbp_projects.dedup();
455    }
456
457    Ok(ports)
458}
459
460fn resolve_xbp_project_for_pid(
461    pid: u32,
462    system: &System,
463    known_projects: &[KnownXbpProject],
464) -> Option<String> {
465    let process = system.process(Pid::from_u32(pid))?;
466
467    if let Some(project) = process
468        .cwd()
469        .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
470    {
471        return Some(project);
472    }
473
474    process
475        .exe()
476        .and_then(|path| path.parent())
477        .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
478}
479
480fn canonicalize_or_fallback(path: &Path) -> PathBuf {
481    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
482}
483
484pub fn expand_home_in_string(input: &str) -> String {
485    let home = dirs::home_dir()
486        .unwrap_or_else(|| std::path::PathBuf::from("."))
487        .to_string_lossy()
488        .to_string();
489
490    if input == "~" {
491        return home;
492    }
493
494    if let Some(rest) = input
495        .strip_prefix("~/")
496        .or_else(|| input.strip_prefix("~\\"))
497    {
498        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
499    }
500
501    if let Some(rest) = input
502        .strip_prefix("$HOME/")
503        .or_else(|| input.strip_prefix("$HOME\\"))
504    {
505        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
506    }
507
508    if let Some(rest) = input
509        .strip_prefix("${HOME}/")
510        .or_else(|| input.strip_prefix("${HOME}\\"))
511    {
512        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
513    }
514
515    input.to_string()
516}
517
518pub fn collapse_home_to_env(input: &str) -> String {
519    let home = dirs::home_dir()
520        .unwrap_or_else(|| std::path::PathBuf::from("."))
521        .to_string_lossy()
522        .to_string();
523
524    if input == home {
525        return "$HOME".to_string();
526    }
527
528    if let Some(rest) = input.strip_prefix(&(home.clone() + "/")) {
529        return format!("$HOME/{}", rest);
530    }
531
532    if let Some(rest) = input.strip_prefix(&(home.clone() + "\\")) {
533        return format!("$HOME\\{}", rest);
534    }
535
536    input.to_string()
537}
538
539pub fn cargo_program() -> PathBuf {
540    std::env::var_os("CARGO")
541        .map(PathBuf::from)
542        .unwrap_or_else(|| PathBuf::from("cargo"))
543}
544
545pub fn cargo_command_exists() -> bool {
546    let program = cargo_program();
547    if program.is_absolute() {
548        return program.is_file();
549    }
550    command_exists(program.to_string_lossy().as_ref())
551}
552
553pub fn command_exists(program: &str) -> bool {
554    let Some(path_var) = std::env::var_os("PATH") else {
555        return false;
556    };
557
558    for dir in std::env::split_paths(&path_var) {
559        let candidate = dir.join(program);
560        if candidate.is_file() {
561            return true;
562        }
563
564        #[cfg(windows)]
565        for ext in ["exe", "cmd", "bat"] {
566            let candidate = dir.join(format!("{}.{}", program, ext));
567            if candidate.is_file() {
568                return true;
569            }
570        }
571    }
572
573    false
574}
575
576pub fn git_remote_url_from_metadata(
577    project_root: &Path,
578    remote: &str,
579) -> Result<Option<String>, String> {
580    let Some(git_dir) = resolve_git_dir(project_root)? else {
581        return Ok(None);
582    };
583
584    let config_path = git_dir.join("config");
585    if !config_path.exists() {
586        return Ok(None);
587    }
588
589    let content = fs::read_to_string(&config_path)
590        .map_err(|e| format!("Failed to read git config {}: {}", config_path.display(), e))?;
591
592    Ok(parse_git_remote_url_from_config(&content, remote))
593}
594
595pub fn parse_github_repo_from_remote_url(url: &str) -> Option<(String, String)> {
596    let normalized: &str = url.trim();
597
598    let repo_path: String = if let Some(path) = normalized.strip_prefix("git@github.com:") {
599        path.to_string()
600    } else if let Some(path) = parse_github_https_repo_path(normalized) {
601        path
602    } else if let Some(path) = normalized.strip_prefix("ssh://git@github.com/") {
603        path.to_string()
604    } else {
605        return None;
606    };
607
608    let cleaned: &str = repo_path.trim_end_matches('/').trim_end_matches(".git");
609    let mut segments: std::str::Split<'_, char> = cleaned.split('/');
610    let owner: &str = segments.next()?.trim();
611    let repo: &str = segments.next()?.trim();
612    if owner.is_empty() || repo.is_empty() || segments.next().is_some() {
613        return None;
614    }
615
616    Some((owner.to_string(), repo.to_string()))
617}
618
619pub fn redact_remote_url_credentials(url: &str) -> String {
620    if !url.contains('@') || !url.contains("://") {
621        return url.to_string();
622    }
623    let mut parsed: reqwest::Url = match reqwest::Url::parse(url) {
624        Ok(value) => value,
625        Err(_) => return url.to_string(),
626    };
627    if parsed.password().is_some() {
628        let _ = parsed.set_password(Some("REDACTED"));
629    }
630    if !parsed.username().is_empty() {
631        let _ = parsed.set_username("REDACTED");
632    }
633    parsed.to_string()
634}
635
636fn resolve_git_dir(project_root: &Path) -> Result<Option<PathBuf>, String> {
637    let dot_git = project_root.join(".git");
638    if dot_git.is_dir() {
639        return Ok(Some(dot_git));
640    }
641
642    if !dot_git.exists() {
643        return Ok(None);
644    }
645
646    let content = fs::read_to_string(&dot_git)
647        .map_err(|e| format!("Failed to read git metadata {}: {}", dot_git.display(), e))?;
648    let git_dir = content
649        .lines()
650        .find_map(|line| line.trim().strip_prefix("gitdir:").map(str::trim))
651        .filter(|value| !value.is_empty())
652        .ok_or_else(|| format!("Failed to parse gitdir pointer from {}", dot_git.display()))?;
653
654    let git_dir_path = PathBuf::from(git_dir);
655    let resolved = if git_dir_path.is_absolute() {
656        git_dir_path
657    } else {
658        dot_git.parent().unwrap_or(project_root).join(git_dir_path)
659    };
660
661    Ok(Some(resolved))
662}
663
664fn parse_git_remote_url_from_config(content: &str, remote: &str) -> Option<String> {
665    let expected_quoted = format!(r#"remote "{}""#, remote);
666    let expected_dotted = format!("remote.{}", remote);
667    let mut in_target_section = false;
668
669    for line in content.lines() {
670        let trimmed = line.trim();
671        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
672            continue;
673        }
674
675        if trimmed.starts_with('[') && trimmed.ends_with(']') {
676            let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
677            in_target_section = section.eq_ignore_ascii_case(&expected_quoted)
678                || section.eq_ignore_ascii_case(&expected_dotted);
679            continue;
680        }
681
682        if !in_target_section {
683            continue;
684        }
685
686        let Some((key, value)) = trimmed.split_once('=') else {
687            continue;
688        };
689        if key.trim().eq_ignore_ascii_case("url") {
690            let value = value.trim();
691            if !value.is_empty() {
692                return Some(value.to_string());
693            }
694        }
695    }
696
697    None
698}
699
700fn parse_github_https_repo_path(url: &str) -> Option<String> {
701    let parsed: reqwest::Url = reqwest::Url::parse(url).ok()?;
702    if !matches!(parsed.scheme(), "http" | "https") {
703        return None;
704    }
705    if parsed.host_str()?.eq_ignore_ascii_case("github.com") {
706        return Some(parsed.path().trim_start_matches('/').to_string());
707    }
708    None
709}
710
711pub fn first_available_command(candidates: &[&str]) -> Option<String> {
712    candidates
713        .iter()
714        .find(|candidate| command_exists(candidate))
715        .map(|candidate| (*candidate).to_string())
716}
717
718pub fn preferred_python_command() -> String {
719    first_available_command(&["python3", "python"]).unwrap_or_else(|| {
720        if cfg!(target_os = "windows") {
721            "python".to_string()
722        } else {
723            "python3".to_string()
724        }
725    })
726}
727
728pub fn preferred_pip_command() -> String {
729    first_available_command(&["pip3", "pip"]).unwrap_or_else(|| {
730        if cfg!(target_os = "windows") {
731            "pip".to_string()
732        } else {
733            "pip3".to_string()
734        }
735    })
736}
737
738pub fn open_with_default_handler(target: &str) -> Result<(), String> {
739    let mut command = if cfg!(target_os = "windows") {
740        let mut cmd = Command::new("cmd");
741        cmd.arg("/C").arg("start").arg("").arg(target);
742        cmd
743    } else if cfg!(target_os = "macos") {
744        let mut cmd = Command::new("open");
745        cmd.arg(target);
746        cmd
747    } else {
748        let mut cmd = Command::new("xdg-open");
749        cmd.arg(target);
750        cmd
751    };
752
753    command
754        .spawn()
755        .map_err(|e| format!("Failed to open '{}': {}", target, e))?;
756    Ok(())
757}
758
759pub fn open_path_with_editor(path: &Path) -> Result<(), String> {
760    if let Ok(editor) = std::env::var("EDITOR") {
761        let mut parts = editor.split_whitespace();
762        let binary = parts
763            .next()
764            .ok_or_else(|| "EDITOR is set but empty".to_string())?;
765        let mut command = Command::new(binary);
766        for part in parts {
767            command.arg(part);
768        }
769        command
770            .arg(path)
771            .spawn()
772            .map_err(|e| format!("Failed to launch editor '{}': {}", editor, e))?;
773        return Ok(());
774    }
775
776    open_with_default_handler(&path.display().to_string())
777}
778
779/// Metadata about structural YAML/JSON fixes applied before field-level auto-heal.
780#[derive(Debug, Clone, Default, PartialEq, Eq)]
781struct ConfigParseNormalizeMeta {
782    /// Leading UTF-8 BOM was present and stripped.
783    had_bom: bool,
784    /// File contained more than one YAML document; documents were merged.
785    multi_document: bool,
786    /// Duplicate top-level keys were dropped (later nulls lose to earlier values).
787    duplicate_top_level_keys: bool,
788}
789
790impl ConfigParseNormalizeMeta {
791    fn needs_rewrite(&self) -> bool {
792        self.had_bom || self.multi_document || self.duplicate_top_level_keys
793    }
794}
795
796/// Drop later top-level keys that would make `serde_yaml` fail with
797/// `duplicate entry with key "…"`. Keeps the first occurrence of each key.
798///
799/// Large monorepo configs sometimes end up with a second `openapi: null` (or
800/// similar) after the services list when generators append fields at the wrong
801/// indent level.
802fn dedupe_yaml_top_level_keys(content: &str) -> (String, bool) {
803    let mut output = String::with_capacity(content.len());
804    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
805    let mut skipping_duplicate_block = false;
806    let mut changed = false;
807
808    for raw_line in content.split_inclusive('\n') {
809        let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
810        let newline = if raw_line.ends_with('\n') { "\n" } else { "" };
811        let trimmed = line.trim_start();
812        let indent = line.len().saturating_sub(trimmed.len());
813
814        // Document separators reset key tracking within multi-doc streams.
815        if trimmed == "---" || trimmed.starts_with("--- ") {
816            skipping_duplicate_block = false;
817            seen.clear();
818            output.push_str(line);
819            output.push_str(newline);
820            continue;
821        }
822
823        if indent == 0 && !trimmed.is_empty() && !trimmed.starts_with('#') {
824            // Top-level mapping key: `key:` or `key: value`
825            if let Some(key) = top_level_yaml_key(trimmed) {
826                if !seen.insert(key) {
827                    // Skip this key and any nested indented block that follows.
828                    skipping_duplicate_block = true;
829                    changed = true;
830                    continue;
831                }
832                skipping_duplicate_block = false;
833            } else {
834                // Top-level list item or scalar — stop skipping.
835                skipping_duplicate_block = false;
836            }
837        } else if skipping_duplicate_block {
838            // Nested content of a dropped duplicate key.
839            if indent > 0 || trimmed.is_empty() || trimmed.starts_with('#') {
840                changed = true;
841                continue;
842            }
843            skipping_duplicate_block = false;
844        }
845
846        if skipping_duplicate_block {
847            changed = true;
848            continue;
849        }
850
851        output.push_str(line);
852        output.push_str(newline);
853    }
854
855    (output, changed)
856}
857
858fn top_level_yaml_key(trimmed_line: &str) -> Option<String> {
859    if trimmed_line.starts_with('-') {
860        return None;
861    }
862    let without_comment = trimmed_line
863        .split_once(" #")
864        .map(|(left, _)| left)
865        .unwrap_or(trimmed_line)
866        .trim_end();
867    let (key, rest) = without_comment.split_once(':')?;
868    let key = key.trim();
869    if key.is_empty() || key.contains(' ') || key.contains('\t') {
870        return None;
871    }
872    // `http://...` style bare strings are not keys.
873    if rest.starts_with("//") {
874        return None;
875    }
876    Some(key.to_string())
877}
878
879/// Parse YAML that may include a UTF-8 BOM and/or multiple documents.
880///
881/// Windows editors often write a BOM; `serde_yaml::from_str` then fails with
882/// "deserializing from YAML containing more than one document is not supported".
883/// Multi-document streams (split by `---`) are deep-merged into one mapping so
884/// legacy/split configs still load. Callers rewrite a single clean document.
885/// Duplicate top-level keys are dropped (first wins) so monorepo configs with a
886/// stray trailing `openapi: null` still load.
887fn parse_yaml_config_documents(
888    content: &str,
889) -> Result<(serde_yaml::Value, ConfigParseNormalizeMeta), String> {
890    let had_bom = content.starts_with('\u{feff}');
891    let stripped = strip_utf8_bom(content);
892    let (deduped, duplicate_top_level_keys) = dedupe_yaml_top_level_keys(stripped);
893    let stripped = deduped.as_str();
894
895    let mut documents = Vec::new();
896    for document in serde_yaml::Deserializer::from_str(stripped) {
897        let value = serde_yaml::Value::deserialize(document).map_err(|e| e.to_string())?;
898        // Skip empty documents that appear between `---` separators.
899        if !matches!(value, serde_yaml::Value::Null) {
900            documents.push(value);
901        }
902    }
903
904    if documents.is_empty() {
905        return Ok((
906            serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
907            ConfigParseNormalizeMeta {
908                had_bom,
909                multi_document: false,
910                duplicate_top_level_keys,
911            },
912        ));
913    }
914
915    let multi_document = documents.len() > 1;
916    let merged = if multi_document {
917        merge_yaml_values(documents)
918    } else {
919        documents
920            .into_iter()
921            .next()
922            .unwrap_or(serde_yaml::Value::Null)
923    };
924
925    Ok((
926        merged,
927        ConfigParseNormalizeMeta {
928            had_bom,
929            multi_document,
930            duplicate_top_level_keys,
931        },
932    ))
933}
934
935fn merge_yaml_values(documents: Vec<serde_yaml::Value>) -> serde_yaml::Value {
936    let mut merged = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
937    for document in documents {
938        deep_merge_yaml(&mut merged, document);
939    }
940    merged
941}
942
943/// Deep-merge `incoming` into `base`. Mappings merge recursively; sequences and
944/// scalars from later documents replace earlier values.
945fn deep_merge_yaml(base: &mut serde_yaml::Value, incoming: serde_yaml::Value) {
946    match (base, incoming) {
947        (serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(incoming_map)) => {
948            for (key, value) in incoming_map {
949                match base_map.get_mut(&key) {
950                    Some(existing) => deep_merge_yaml(existing, value),
951                    None => {
952                        base_map.insert(key, value);
953                    }
954                }
955            }
956        }
957        (base_slot, incoming) => {
958            *base_slot = incoming;
959        }
960    }
961}
962
963pub fn parse_config_with_auto_heal<T: DeserializeOwned>(
964    content: &str,
965    kind: &str,
966) -> Result<(T, Option<String>), String> {
967    let (mut value, structural_heal) = match kind {
968        "yaml" => {
969            let (yaml_value, meta) = parse_yaml_config_documents(content)?;
970            let json = serde_json::to_value(yaml_value).map_err(|e| e.to_string())?;
971            (json, meta.needs_rewrite())
972        }
973        "json" => {
974            let had_bom = content.starts_with('\u{feff}');
975            let stripped = strip_utf8_bom(content);
976            let json = serde_json::from_str::<Value>(stripped).map_err(|e| e.to_string())?;
977            (json, had_bom)
978        }
979        _ => return Err(format!("Unsupported config kind: {}", kind)),
980    };
981
982    let field_healed = auto_heal_xbp_config_value(&mut value);
983    let healed = field_healed || structural_heal;
984    let parsed = serde_json::from_value::<T>(value.clone()).map_err(|e| e.to_string())?;
985
986    let healed_content = if healed {
987        Some(match kind {
988            "yaml" => serialize_xbp_yaml(&value)?,
989            "json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
990            _ => unreachable!(),
991        })
992    } else {
993        None
994    };
995
996    Ok((parsed, healed_content))
997}
998
999#[derive(Debug, Clone, PartialEq, Eq)]
1000pub struct XbpConfigHealResult {
1001    pub config_path: PathBuf,
1002    pub fixes: Vec<String>,
1003}
1004
1005pub fn detect_xbp_config_heal_opportunities(content: &str) -> Vec<String> {
1006    let mut fixes = Vec::new();
1007
1008    if content.starts_with('\u{feff}') {
1009        fixes.push("strip leading UTF-8 BOM".to_string());
1010    }
1011
1012    let stripped = strip_utf8_bom(content);
1013    // Count non-empty YAML documents; multi-doc streams are collapsed on load.
1014    if content_looks_like_yaml_multidoc(stripped) {
1015        fixes.push("collapse multi-document YAML into a single document".to_string());
1016    }
1017
1018    for line in content.lines() {
1019        let trimmed = line.trim();
1020        if !trimmed.starts_with("systemd:") {
1021            continue;
1022        }
1023        let value = trimmed.trim_start_matches("systemd:").trim();
1024        if value.is_empty() || value == "null" || value.starts_with('{') {
1025            continue;
1026        }
1027        let normalized = value.trim_matches('"').trim_matches('\'');
1028        fixes.push(format!(
1029            "normalize `systemd: {normalized}` to `systemd_service_name`"
1030        ));
1031    }
1032
1033    fixes.sort();
1034    fixes.dedup();
1035    fixes
1036}
1037
1038fn content_looks_like_yaml_multidoc(content: &str) -> bool {
1039    let mut document_count = 0usize;
1040    for document in serde_yaml::Deserializer::from_str(content) {
1041        match serde_yaml::Value::deserialize(document) {
1042            Ok(serde_yaml::Value::Null) => {}
1043            Ok(_) => {
1044                document_count += 1;
1045                if document_count > 1 {
1046                    return true;
1047                }
1048            }
1049            Err(_) => return false,
1050        }
1051    }
1052    false
1053}
1054
1055pub fn heal_project_xbp_config(start_dir: &Path) -> Result<Option<XbpConfigHealResult>, String> {
1056    let found = match find_xbp_config_upwards(start_dir) {
1057        Some(found) => found,
1058        None => return Ok(None),
1059    };
1060
1061    let content = fs::read_to_string(&found.config_path)
1062        .map_err(|e| format!("Failed to read {}: {}", found.config_path.display(), e))?;
1063    let fixes = detect_xbp_config_heal_opportunities(&content);
1064    let healed = heal_config_file(&found.config_path, found.kind)?;
1065
1066    if !healed && fixes.is_empty() {
1067        return Ok(None);
1068    }
1069
1070    Ok(Some(XbpConfigHealResult {
1071        config_path: found.config_path,
1072        fixes: if fixes.is_empty() {
1073            vec!["normalized legacy config fields".to_string()]
1074        } else {
1075            fixes
1076        },
1077    }))
1078}
1079
1080pub fn heal_config_file(path: &Path, kind: &str) -> Result<bool, String> {
1081    let content = fs::read_to_string(path)
1082        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1083    let (_, healed_content) = parse_config_with_auto_heal::<Value>(&content, kind)?;
1084
1085    if let Some(healed_content) = healed_content {
1086        fs::write(path, healed_content)
1087            .map_err(|e| format!("Failed to write healed config {}: {}", path.display(), e))?;
1088        return Ok(true);
1089    }
1090
1091    Ok(false)
1092}
1093
1094pub(crate) fn normalize_xbp_config_value(value: &mut Value) -> bool {
1095    auto_heal_xbp_config_value(value)
1096}
1097
1098fn auto_heal_xbp_config_value(value: &mut Value) -> bool {
1099    let Some(root) = value.as_object_mut() else {
1100        return false;
1101    };
1102
1103    let mut changed = false;
1104
1105    if let Some(environment) = root.get_mut("environment") {
1106        changed |= normalize_environment_value(environment);
1107    }
1108
1109    changed |= normalize_systemd_shorthand(root);
1110
1111    if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
1112        for service in services {
1113            let Some(service) = service.as_object_mut() else {
1114                continue;
1115            };
1116
1117            if let Some(environment) = service.get_mut("environment") {
1118                changed |= normalize_environment_value(environment);
1119            }
1120
1121            changed |= normalize_systemd_shorthand(service);
1122        }
1123    }
1124
1125    changed
1126}
1127
1128fn normalize_systemd_shorthand(map: &mut Map<String, Value>) -> bool {
1129    let Some(systemd) = map.get("systemd") else {
1130        return false;
1131    };
1132
1133    let Value::String(service_name) = systemd else {
1134        return false;
1135    };
1136
1137    let systemd_service_name_missing = map
1138        .get("systemd_service_name")
1139        .map(|value| value.is_null())
1140        .unwrap_or(true);
1141
1142    if systemd_service_name_missing && !service_name.is_empty() {
1143        map.insert(
1144            "systemd_service_name".to_string(),
1145            Value::String(service_name.clone()),
1146        );
1147    }
1148
1149    map.remove("systemd");
1150    true
1151}
1152
1153fn normalize_environment_value(value: &mut Value) -> bool {
1154    let Value::Object(map) = value else {
1155        return false;
1156    };
1157
1158    let original = map.clone();
1159    let mut normalized = Map::new();
1160    let mut changed = false;
1161
1162    flatten_environment_entries(&original, &mut normalized, &mut changed);
1163
1164    if normalized != original {
1165        *map = normalized;
1166        changed = true;
1167    }
1168
1169    changed
1170}
1171
1172fn flatten_environment_entries(
1173    source: &Map<String, Value>,
1174    target: &mut Map<String, Value>,
1175    changed: &mut bool,
1176) {
1177    for (key, value) in source {
1178        match value {
1179            Value::String(string) => {
1180                target.insert(key.clone(), Value::String(string.clone()));
1181            }
1182            Value::Number(number) => {
1183                *changed = true;
1184                target.insert(key.clone(), Value::String(number.to_string()));
1185            }
1186            Value::Bool(boolean) => {
1187                *changed = true;
1188                target.insert(key.clone(), Value::String(boolean.to_string()));
1189            }
1190            Value::Null => {
1191                *changed = true;
1192                target.insert(key.clone(), Value::String(String::new()));
1193            }
1194            Value::Array(items) => {
1195                *changed = true;
1196                let serialized = serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string());
1197                target.insert(key.clone(), Value::String(serialized));
1198            }
1199            Value::Object(nested) => {
1200                *changed = true;
1201                flatten_environment_entries(nested, target, changed);
1202            }
1203        }
1204    }
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209    use super::{
1210        command_exists, detect_xbp_config_heal_opportunities, first_available_command,
1211        git_remote_url_from_metadata, maybe_auto_convert_legacy_xbp_json_to_yaml,
1212        parse_config_with_auto_heal, parse_github_repo_from_remote_url, preferred_pip_command,
1213        preferred_python_command, redact_remote_url_credentials, resolve_xbp_project_for_path,
1214        write_json_config_from_any_xbp_config, KnownXbpProject,
1215    };
1216    use crate::strategies::XbpConfig;
1217    use std::fs;
1218    use std::path::PathBuf;
1219    use std::sync::{Mutex, OnceLock};
1220
1221    fn path_lock() -> &'static Mutex<()> {
1222        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1223        LOCK.get_or_init(|| Mutex::new(()))
1224    }
1225
1226    fn make_temp_path(name: &str) -> PathBuf {
1227        let mut path = std::env::temp_dir();
1228        path.push(format!("xbp-test-{}-{}", name, std::process::id()));
1229        path
1230    }
1231
1232    fn with_path<F>(entries: &[PathBuf], test: F)
1233    where
1234        F: FnOnce(),
1235    {
1236        let _guard = path_lock().lock().expect("path lock should be available");
1237        let original = std::env::var_os("PATH");
1238        let joined = std::env::join_paths(entries).expect("PATH entries should join");
1239        std::env::set_var("PATH", joined);
1240        test();
1241        match original {
1242            Some(path) => std::env::set_var("PATH", path),
1243            None => std::env::remove_var("PATH"),
1244        }
1245    }
1246
1247    #[test]
1248    fn parses_yaml_with_leading_utf8_bom_and_rewrites_clean_file() {
1249        // serde_yaml::from_str fails on BOM with a misleading multi-document error.
1250        let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n";
1251
1252        let (config, healed_content) =
1253            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("BOM yaml should parse");
1254
1255        assert_eq!(config.project_name, "demo");
1256        assert_eq!(config.port, 3000);
1257        let healed = healed_content.expect("BOM should trigger autonomous rewrite");
1258        assert!(
1259            !healed.starts_with('\u{feff}'),
1260            "healed yaml must not retain BOM"
1261        );
1262        assert!(healed.contains("project_name: demo"));
1263    }
1264
1265    #[test]
1266    fn drops_duplicate_top_level_openapi_null_and_keeps_first_block() {
1267        let yaml = r#"
1268project_name: athena
1269version: 1.0.0
1270port: 4052
1271build_dir: ./
1272openapi:
1273  enabled: true
1274  auto_detect_services: false
1275services:
1276  - name: athena
1277    target: rust
1278    branch: main
1279    port: 4052
1280    openapi: null
1281openapi: null
1282workers:
1283  - name: athena-auth
1284    root: services/athena-auth
1285"#;
1286
1287        let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
1288            .expect("duplicate openapi key should be healed");
1289        assert_eq!(config.project_name, "athena");
1290        assert_eq!(
1291            config.openapi.as_ref().and_then(|o| o.enabled),
1292            Some(true)
1293        );
1294        assert!(config.workers.is_some());
1295        let healed = healed_content.expect("duplicate key should trigger rewrite");
1296        let top_level_openapi_lines = healed
1297            .lines()
1298            .filter(|line| line.starts_with("openapi:"))
1299            .count();
1300        assert_eq!(
1301            top_level_openapi_lines, 1,
1302            "healed yaml should keep a single top-level openapi block: {healed}"
1303        );
1304        assert!(
1305            !healed.contains("\nopenapi: null\n") && !healed.ends_with("openapi: null"),
1306            "stray top-level openapi: null must be dropped: {healed}"
1307        );
1308    }
1309
1310    #[test]
1311    fn merges_multi_document_yaml_and_rewrites_single_document() {
1312        let yaml = r#"
1313project_name: demo
1314port: 3000
1315build_dir: $HOME/demo
1316---
1317environment:
1318  LOG_LEVEL: info
1319---
1320services:
1321  - name: api
1322    target: rust
1323    branch: main
1324    port: 3001
1325"#;
1326
1327        let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
1328            .expect("multi-document yaml should merge");
1329
1330        assert_eq!(config.project_name, "demo");
1331        assert_eq!(config.port, 3000);
1332        let environment = config.environment.expect("merged environment should exist");
1333        assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
1334        let services = config.services.expect("merged services should exist");
1335        assert_eq!(services.len(), 1);
1336        assert_eq!(services[0].name, "api");
1337
1338        let healed = healed_content.expect("multi-doc should trigger rewrite");
1339        assert!(
1340            !healed.lines().any(|line| line.trim() == "---"),
1341            "healed yaml should be a single document"
1342        );
1343        assert!(healed.contains("project_name: demo"));
1344        assert!(healed.contains("LOG_LEVEL"));
1345        assert!(healed.contains("name: api"));
1346    }
1347
1348    #[test]
1349    fn parses_bom_prefixed_multi_document_yaml() {
1350        let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: ./\n---\nversion: 1.2.3\n";
1351
1352        let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
1353            .expect("BOM + multi-doc yaml should parse");
1354
1355        assert_eq!(config.project_name, "demo");
1356        assert_eq!(config.version, "1.2.3");
1357        assert!(healed_content.is_some());
1358    }
1359
1360    #[test]
1361    fn detect_heal_opportunities_reports_bom_and_multidoc() {
1362        let yaml = "\u{feff}project_name: demo\n---\nsystemd: athena.service\n";
1363        let fixes = detect_xbp_config_heal_opportunities(yaml);
1364        assert!(
1365            fixes.iter().any(|fix| fix.contains("UTF-8 BOM")),
1366            "expected BOM fix, got {fixes:?}"
1367        );
1368        assert!(
1369            fixes.iter().any(|fix| fix.contains("multi-document")),
1370            "expected multi-doc fix, got {fixes:?}"
1371        );
1372        assert!(
1373            fixes.iter().any(|fix| fix.contains("systemd")),
1374            "expected systemd fix, got {fixes:?}"
1375        );
1376    }
1377
1378    #[test]
1379    fn heal_config_file_strips_bom_on_disk() {
1380        let project_root = make_temp_path("heal-bom-yaml");
1381        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1382        let yaml_path = project_root.join(".xbp").join("xbp.yaml");
1383        fs::write(
1384            &yaml_path,
1385            "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
1386        )
1387        .expect("yaml should be written with BOM");
1388
1389        let healed = super::heal_config_file(&yaml_path, "yaml").expect("heal should succeed");
1390        assert!(healed, "BOM config should be rewritten");
1391
1392        let rewritten = fs::read_to_string(&yaml_path).expect("healed yaml should be readable");
1393        assert!(
1394            !rewritten.starts_with('\u{feff}'),
1395            "rewritten file must not start with BOM"
1396        );
1397        assert!(rewritten.contains("project_name: demo"));
1398
1399        // Second pass is a no-op once the file is clean (unless field heals apply).
1400        let (config, second_heal) =
1401            parse_config_with_auto_heal::<XbpConfig>(&rewritten, "yaml").expect("reparse");
1402        assert_eq!(config.project_name, "demo");
1403        // Environment/systemd heals may not apply; structural heal should be false.
1404        // Field heals are also false for this minimal fixture.
1405        assert!(second_heal.is_none());
1406
1407        fs::remove_dir_all(project_root).expect("temp project should be removed");
1408    }
1409
1410    #[test]
1411    fn heals_nested_yaml_environment_blocks() {
1412        let yaml = r#"
1413project_name: demo
1414port: 3000
1415build_dir: $HOME/demo
1416environment:
1417  production:
1418    DATABASE_URL: postgres://localhost/demo
1419    LOG_LEVEL: info
1420services:
1421  - name: api
1422    target: rust
1423    branch: main
1424    port: 3001
1425    environment:
1426      production:
1427        SERVICE_TOKEN: abc123
1428"#;
1429
1430        let (config, healed_content) =
1431            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1432
1433        let environment = config.environment.expect("top-level env should exist");
1434        assert_eq!(
1435            environment.get("DATABASE_URL"),
1436            Some(&"postgres://localhost/demo".to_string())
1437        );
1438        assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
1439
1440        let service_environment = config.services.expect("services should exist")[0]
1441            .environment
1442            .clone()
1443            .expect("service env should exist");
1444        assert_eq!(
1445            service_environment.get("SERVICE_TOKEN"),
1446            Some(&"abc123".to_string())
1447        );
1448        assert!(healed_content.is_some());
1449    }
1450
1451    #[test]
1452    fn detect_xbp_config_heal_opportunities_finds_systemd_shorthand() {
1453        let yaml = r#"
1454services:
1455  - name: api
1456    systemd: athena.service
1457    systemd_service_name: athena
1458"#;
1459        let fixes = detect_xbp_config_heal_opportunities(yaml);
1460        assert_eq!(fixes.len(), 1);
1461        assert!(fixes[0].contains("athena.service"));
1462    }
1463
1464    #[test]
1465    fn heals_systemd_string_shorthand_in_yaml() {
1466        let yaml = r#"
1467project_name: athena
1468port: 3000
1469build_dir: $HOME/athena
1470systemd: athena.service
1471services:
1472  - name: api
1473    target: rust
1474    branch: main
1475    port: 3001
1476    systemd: athena-api.service
1477"#;
1478
1479        let (config, healed_content) =
1480            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1481
1482        assert_eq!(
1483            config.systemd_service_name.as_deref(),
1484            Some("athena.service")
1485        );
1486        assert!(config.systemd.is_none());
1487
1488        let service = &config.services.expect("services should exist")[0];
1489        assert_eq!(
1490            service.systemd_service_name.as_deref(),
1491            Some("athena-api.service")
1492        );
1493        assert!(service.systemd.is_none());
1494        assert!(healed_content.is_some());
1495        assert!(!healed_content
1496            .expect("healed content should exist")
1497            .contains("systemd: athena"));
1498    }
1499
1500    #[test]
1501    fn heals_systemd_string_shorthand_without_overwriting_existing_service_name() {
1502        let yaml = r#"
1503project_name: athena
1504port: 4052
1505build_dir: $HOME/athena
1506services:
1507  - name: athena-gateway
1508    target: rust
1509    branch: main
1510    port: 4052
1511    systemd: athena.service
1512    systemd_service_name: athena
1513"#;
1514
1515        let (config, healed_content) =
1516            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1517
1518        let service = &config.services.expect("services should exist")[0];
1519        assert_eq!(service.systemd_service_name.as_deref(), Some("athena"));
1520        assert!(service.systemd.is_none());
1521        assert!(healed_content.is_some());
1522    }
1523
1524    #[test]
1525    fn heals_non_string_environment_values_in_json() {
1526        let json = r#"{
1527  "project_name": "demo",
1528  "port": 3000,
1529  "build_dir": "$HOME/demo",
1530  "environment": {
1531    "PORT": 3000,
1532    "DEBUG": true,
1533    "EMPTY": null
1534  }
1535}"#;
1536
1537        let (config, healed_content) =
1538            parse_config_with_auto_heal::<XbpConfig>(json, "json").expect("config should heal");
1539
1540        let environment = config.environment.expect("top-level env should exist");
1541        assert_eq!(environment.get("PORT"), Some(&"3000".to_string()));
1542        assert_eq!(environment.get("DEBUG"), Some(&"true".to_string()));
1543        assert_eq!(environment.get("EMPTY"), Some(&String::new()));
1544        assert!(healed_content.is_some());
1545    }
1546
1547    #[test]
1548    fn command_helpers_respect_path_order() {
1549        let bin_dir = make_temp_path("bin");
1550        fs::create_dir_all(&bin_dir).expect("temp dir should be created");
1551        fs::write(bin_dir.join("python"), b"").expect("python file should be created");
1552        fs::write(bin_dir.join("pip"), b"").expect("pip file should be created");
1553
1554        with_path(std::slice::from_ref(&bin_dir), || {
1555            assert!(command_exists("python"));
1556            assert_eq!(
1557                first_available_command(&["python3", "python"]),
1558                Some("python".to_string())
1559            );
1560            assert_eq!(preferred_python_command(), "python".to_string());
1561            assert_eq!(preferred_pip_command(), "pip".to_string());
1562        });
1563
1564        fs::remove_dir_all(&bin_dir).expect("temp dir should be removed");
1565    }
1566
1567    #[test]
1568    fn resolves_xbp_project_for_nested_paths() {
1569        let project_root = make_temp_path("ownership");
1570        let service_dir = project_root.join("services").join("api");
1571        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1572        fs::create_dir_all(&service_dir).expect("service dir should be created");
1573        fs::write(
1574            project_root.join(".xbp").join("xbp.json"),
1575            br#"{"project_name":"demo"}"#,
1576        )
1577        .expect("xbp config should be written");
1578
1579        let known_projects = vec![KnownXbpProject {
1580            root: project_root.clone(),
1581            name: "demo".to_string(),
1582        }];
1583
1584        assert_eq!(
1585            resolve_xbp_project_for_path(&service_dir, &known_projects),
1586            Some("demo".to_string())
1587        );
1588
1589        fs::remove_dir_all(&project_root).expect("temp project should be removed");
1590    }
1591
1592    #[test]
1593    fn ignores_non_xbp_paths_for_project_resolution() {
1594        let project_root = make_temp_path("ownership-miss");
1595        let other_dir = make_temp_path("ownership-other");
1596        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1597        fs::create_dir_all(&other_dir).expect("other dir should be created");
1598        fs::write(
1599            project_root.join(".xbp").join("xbp.json"),
1600            br#"{"project_name":"demo"}"#,
1601        )
1602        .expect("xbp config should be written");
1603
1604        let known_projects = vec![KnownXbpProject {
1605            root: project_root.clone(),
1606            name: "demo".to_string(),
1607        }];
1608
1609        assert_eq!(
1610            resolve_xbp_project_for_path(&other_dir, &known_projects),
1611            None
1612        );
1613
1614        fs::remove_dir_all(&project_root).expect("temp project should be removed");
1615        fs::remove_dir_all(&other_dir).expect("temp other dir should be removed");
1616    }
1617
1618    #[test]
1619    fn auto_converts_legacy_json_to_yaml() {
1620        let project_root = make_temp_path("json-to-yaml");
1621        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1622        let json_path = project_root.join(".xbp").join("xbp.json");
1623        fs::write(
1624            &json_path,
1625            br#"{"project_name":"demo","port":3000,"build_dir":"$HOME/demo"}"#,
1626        )
1627        .expect("json should be written");
1628
1629        let output = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &json_path)
1630            .expect("conversion should succeed")
1631            .expect("yaml path should be returned");
1632        assert!(output.exists(), "converted yaml should exist");
1633
1634        let yaml = fs::read_to_string(output).expect("yaml should be readable");
1635        assert!(yaml.contains("project_name: demo"));
1636
1637        fs::remove_dir_all(project_root).expect("temp project should be removed");
1638    }
1639
1640    #[test]
1641    fn normalizes_environment_quotes_in_yaml_output() {
1642        let yaml = super::normalize_xbp_yaml_environment_quotes(
1643            r#"project_name: demo
1644port: 3000
1645build_dir: ./
1646environment:
1647  API_PATH: /api/auth
1648  WITH_DOUBLE: 'look "here"'
1649  WITH_SINGLE: "can't fail"
1650services:
1651  - name: api
1652    target: rust
1653    branch: main
1654    port: 3001
1655    environment:
1656      CALLBACK_PATH: /api/auth/callback
1657      CALLBACK_TITLE: "the 'quoted' callback"
1658"#,
1659        );
1660
1661        assert!(yaml.contains("API_PATH: \"/api/auth\""));
1662        assert!(yaml.contains("WITH_DOUBLE: 'look \"here\"'"));
1663        assert!(yaml.contains("WITH_SINGLE: \"can't fail\""));
1664        assert!(yaml.contains("CALLBACK_PATH: \"/api/auth/callback\""));
1665        assert!(yaml.contains("CALLBACK_TITLE: \"the 'quoted' callback\""));
1666    }
1667
1668    #[test]
1669    fn writes_json_from_yaml_config() {
1670        let project_root = make_temp_path("yaml-to-json");
1671        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1672        let yaml_path = project_root.join(".xbp").join("xbp.yaml");
1673        let json_out = project_root.join("xbp.json");
1674        fs::write(
1675            &yaml_path,
1676            "project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
1677        )
1678        .expect("yaml should be written");
1679
1680        write_json_config_from_any_xbp_config(&yaml_path, &json_out)
1681            .expect("json should be generated from yaml");
1682
1683        let json = fs::read_to_string(json_out).expect("json should be readable");
1684        assert!(json.contains("\"project_name\": \"demo\""));
1685
1686        fs::remove_dir_all(project_root).expect("temp project should be removed");
1687    }
1688
1689    #[test]
1690    fn parses_github_repo_from_supported_remote_urls() {
1691        assert_eq!(
1692            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
1693            Some(("xylex-group".to_string(), "xbp".to_string()))
1694        );
1695        assert_eq!(
1696            parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
1697            Some(("xylex-group".to_string(), "xbp".to_string()))
1698        );
1699        assert_eq!(
1700            parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
1701            Some(("xylex-group".to_string(), "xbp".to_string()))
1702        );
1703        assert_eq!(
1704            parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
1705            None
1706        );
1707        assert_eq!(
1708            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp/extra.git"),
1709            None
1710        );
1711    }
1712
1713    #[test]
1714    fn redacts_credentials_in_remote_urls() {
1715        let redacted = redact_remote_url_credentials("https://token@example.com/xylex-group/xbp");
1716        assert!(redacted.contains("REDACTED"));
1717        assert!(!redacted.contains("token@example.com"));
1718    }
1719
1720    #[test]
1721    fn reads_origin_remote_url_from_git_config_directory() {
1722        let project_root = make_temp_path("git-config-dir");
1723        let git_dir = project_root.join(".git");
1724        fs::create_dir_all(&git_dir).expect("git dir should be created");
1725        fs::write(
1726            git_dir.join("config"),
1727            "[remote \"origin\"]\n\turl = https://github.com/xylex-group/xbp.git\n",
1728        )
1729        .expect("git config should be written");
1730
1731        let remote = git_remote_url_from_metadata(&project_root, "origin")
1732            .expect("git metadata should parse")
1733            .expect("origin remote should exist");
1734        assert_eq!(remote, "https://github.com/xylex-group/xbp.git");
1735
1736        fs::remove_dir_all(project_root).expect("temp project should be removed");
1737    }
1738
1739    #[test]
1740    fn reads_origin_remote_url_from_gitdir_pointer() {
1741        let project_root = make_temp_path("git-config-file");
1742        let nested_git_dir = project_root.join(".git-real");
1743        fs::create_dir_all(&nested_git_dir).expect("gitdir target should be created");
1744        fs::write(project_root.join(".git"), "gitdir: .git-real\n")
1745            .expect("gitdir pointer should be written");
1746        fs::write(
1747            nested_git_dir.join("config"),
1748            "[remote \"origin\"]\n\turl = git@github.com:xylex-group/xbp.git\n",
1749        )
1750        .expect("git config should be written");
1751
1752        let remote = git_remote_url_from_metadata(&project_root, "origin")
1753            .expect("git metadata should parse")
1754            .expect("origin remote should exist");
1755        assert_eq!(remote, "git@github.com:xylex-group/xbp.git");
1756
1757        fs::remove_dir_all(project_root).expect("temp project should be removed");
1758    }
1759}