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