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