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 js_package_manager;
9pub mod node_toolchain;
10pub mod pnpm_package_json;
11pub mod process_monitor_json;
12pub mod project_paths;
13pub mod tray_runtime;
14pub mod version;
15pub mod xbp_ignore;
16
17pub use cargo_manifest::{
18    resolve_cargo_package_version, resolve_cargo_package_version_required,
19    write_cargo_package_version,
20};
21pub use env_files::{
22    canonicalize_env_value, env_reference_name, escape_env_value_double_quoted,
23    first_dotenv_value_upwards, first_lookup_value, format_env_assignment, format_env_file_content,
24    format_env_value_double_quoted, load_env_lookup, normalize_env_key, normalize_env_value,
25    parse_env_content, parse_env_file, preferred_local_secret_env_path, resolve_env_placeholders,
26    secret_like_key, strip_utf8_bom, to_env_references, upsert_env_key,
27    CLOUDFLARE_ACCOUNT_ID_ENV_KEYS, DOTENV_FILE_NAMES,
28};
29pub use node_toolchain::{
30    find_node_toolchain_root, is_node_toolchain_command, node_toolchain_wrapper_path,
31};
32pub use process_monitor_json::{
33    fix_cursor_process_monitor_json, fix_cursor_process_monitor_json_file,
34    CursorProcessMonitorJsonFix,
35};
36pub use js_package_manager::{
37    classify_builds_failure, detect_js_install_package_manager, detect_js_package_manager,
38    ensure_pnpm_workspace_packages, flatten_build_logs_json, is_package_manager_mismatch_error,
39    is_pnpm_packages_field_error, run_package_json_script, suggested_build_command,
40    BuildsFailureClassification, JsPackageManager,
41};
42pub use pnpm_package_json::{migrate_package_json_pnpm_settings, PnpmSettingsMigration};
43pub use project_paths::{
44    canonicalize_for_subprocess, collapse_project_path, resolve_project_dir, resolve_project_path,
45    resolve_service_root,
46};
47pub use version::{fetch_version, increment_version};
48pub use xbp_ignore::{
49    default_global_worktree_ignore_content, default_xbp_ignore_path,
50    load_global_worktree_ignore_from_path, load_project_xbp_ignore, sync_global_worktree_ignore_file,
51    xbp_ignore_file_candidates, XbpIgnoreSet, DEFAULT_DISCOVERY_SKIP_DIRS,
52    DEFAULT_GLOBAL_WORKTREE_IGNORE_PATTERNS, DEFAULT_NOISE_DIR_NAMES,
53    GLOBAL_WORKTREE_IGNORE_FILENAME,
54};
55
56use serde::de::DeserializeOwned;
57use serde::{Deserialize, Serialize};
58use serde_json::{Map, Value};
59use std::collections::{BTreeMap, HashMap, HashSet};
60use std::fs;
61use std::path::{Path, PathBuf};
62use std::process::Command;
63use sysinfo::{Pid, System};
64
65use crate::profile::find_all_xbp_projects;
66
67#[derive(Debug, Clone)]
68pub struct FoundXbpConfig {
69    pub project_root: PathBuf,
70    pub config_path: PathBuf,
71    pub kind: &'static str,
72    pub location: String,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct KnownXbpProject {
77    pub root: PathBuf,
78    pub name: String,
79}
80
81#[derive(Debug, Clone, Default, PartialEq, Eq)]
82pub struct ListeningPortOwnership {
83    pub pids: Vec<u32>,
84    pub xbp_projects: Vec<String>,
85}
86
87/// Supported project config basenames (under `.xbp/` or project root).
88/// Order matches discovery preference (first existing wins).
89pub const PROJECT_CONFIG_FILENAMES: &[&str] = &[
90    "xbp.toml",
91    "xbp.jsonc",
92    "xbp.yaml",
93    "xbp.yml",
94    "xbp.json",
95];
96
97fn project_config_kind_for_filename(name: &str) -> &'static str {
98    match name {
99        "xbp.toml" => "toml",
100        "xbp.jsonc" => "jsonc",
101        "xbp.yaml" | "xbp.yml" => "yaml",
102        "xbp.json" => "json",
103        _ => "yaml",
104    }
105}
106
107/// Human-readable list of supported project config locations.
108pub const PROJECT_CONFIG_HINT: &str =
109    ".xbp/xbp.{toml,jsonc,yaml,yml,json} or xbp.{toml,jsonc,yaml,yml,json}";
110
111/// Default kind used when creating a brand-new project config.
112pub const DEFAULT_PROJECT_CONFIG_KIND: &str = "toml";
113
114/// Repo-relative default path for greenfield project config (shown in prompts/help).
115pub const DEFAULT_PROJECT_CONFIG_RELATIVE: &str = ".xbp/xbp.toml";
116
117/// Preferred path for a **new** project config (`.xbp/xbp.toml`).
118/// Prefer [`resolve_project_config_write_path`] when a config already exists.
119pub fn default_project_yaml_config_path(project_root: &Path) -> PathBuf {
120    default_project_config_path(project_root, DEFAULT_PROJECT_CONFIG_KIND)
121}
122
123/// Repo-relative path of the project config for UI copy.
124///
125/// Uses an existing discovered config when present; otherwise the greenfield
126/// default (`.xbp/xbp.toml`).
127pub fn project_config_display_path(project_root: &Path) -> String {
128    find_existing_project_config(project_root)
129        .map(|path| collapse_project_path(project_root, &path.to_string_lossy()))
130        .filter(|rel| !rel.is_empty())
131        .unwrap_or_else(|| DEFAULT_PROJECT_CONFIG_RELATIVE.to_string())
132}
133
134/// Default config path under `.xbp/` for the given format kind.
135pub fn default_project_config_path(project_root: &Path, kind: &str) -> PathBuf {
136    let filename = match kind {
137        "yaml" | "yml" => "xbp.yaml",
138        "json" => "xbp.json",
139        "jsonc" => "xbp.jsonc",
140        "toml" => "xbp.toml",
141        _ => "xbp.toml",
142    };
143    project_root.join(".xbp").join(filename)
144}
145
146/// All candidate project config paths for a root (`.xbp/` then root, discovery order).
147pub fn project_config_candidates(project_root: &Path) -> Vec<(PathBuf, &'static str)> {
148    let mut out = Vec::with_capacity(PROJECT_CONFIG_FILENAMES.len() * 2);
149    for name in PROJECT_CONFIG_FILENAMES {
150        let kind = project_config_kind_for_filename(name);
151        out.push((project_root.join(".xbp").join(name), kind));
152    }
153    for name in PROJECT_CONFIG_FILENAMES {
154        let kind = project_config_kind_for_filename(name);
155        out.push((project_root.join(name), kind));
156    }
157    out
158}
159
160/// First existing project config under `project_root` (any supported format).
161pub fn find_existing_project_config(project_root: &Path) -> Option<PathBuf> {
162    project_config_candidates(project_root)
163        .into_iter()
164        .map(|(path, _)| path)
165        .find(|path| path.exists())
166}
167
168/// @deprecated Prefer [`find_existing_project_config`].
169pub fn find_existing_yaml_xbp_config(project_root: &Path) -> Option<PathBuf> {
170    let candidates = [
171        project_root.join(".xbp").join("xbp.yaml"),
172        project_root.join(".xbp").join("xbp.yml"),
173        project_root.join("xbp.yaml"),
174        project_root.join("xbp.yml"),
175    ];
176    candidates.into_iter().find(|candidate| candidate.exists())
177}
178
179/// Path to write project config updates:
180/// - existing discovered config path when present
181/// - otherwise default `.xbp/xbp.toml` (greenfield)
182pub fn resolve_project_config_write_path(
183    project_root: &Path,
184    existing: Option<&FoundXbpConfig>,
185) -> PathBuf {
186    if let Some(found) = existing {
187        return found.config_path.clone();
188    }
189    find_existing_project_config(project_root)
190        .unwrap_or_else(|| default_project_config_path(project_root, DEFAULT_PROJECT_CONFIG_KIND))
191}
192
193/// Serialize `value` to the project config format for `kind` (`yaml`/`json`/`jsonc`/`toml`).
194pub fn serialize_xbp_config<T: Serialize>(value: &T, kind: &str) -> Result<String, String> {
195    let mut json = serde_json::to_value(value)
196        .map_err(|e| format!("Failed to prepare project config: {e}"))?;
197    let _ = ensure_named_entry_key_order(&mut json);
198    render_xbp_config_value(&json, kind)
199}
200
201/// Write project config to `config_path` using the format implied by its extension.
202pub fn write_xbp_project_config_at_path<T: Serialize>(
203    config_path: &Path,
204    value: &T,
205) -> Result<(), String> {
206    let kind = config_kind_from_path(config_path)?;
207    if let Some(parent) = config_path.parent() {
208        fs::create_dir_all(parent).map_err(|e| {
209            format!(
210                "Failed to create config directory {}: {e}",
211                parent.display()
212            )
213        })?;
214    }
215    let rendered = match kind {
216        "yaml" | "yml" => serialize_xbp_yaml_for_path(value, Some(config_path))?,
217        other => serialize_xbp_config(value, other)?,
218    };
219    fs::write(config_path, rendered).map_err(|e| {
220        format!(
221            "Failed to write project config {}: {e}",
222            config_path.display()
223        )
224    })
225}
226
227/// Load + auto-heal project config from a discovered path.
228pub fn load_xbp_project_config_value(
229    config_path: &Path,
230) -> Result<(Value, &'static str, Option<String>), String> {
231    let kind = config_kind_from_path(config_path)?;
232    let content = fs::read_to_string(config_path).map_err(|e| {
233        format!(
234            "Failed to read project config {}: {e}",
235            config_path.display()
236        )
237    })?;
238    let (value, healed) = parse_config_with_auto_heal::<Value>(&content, kind)
239        .map_err(|e| format!("Failed to parse project config {}: {e}", config_path.display()))?;
240    Ok((value, kind, healed))
241}
242
243/// Legacy helper: if only `xbp.json` exists and no other format is present, leave JSON as-is
244/// (do **not** force YAML). Returns the path that should be used for subsequent writes.
245///
246/// Historically this auto-converted JSON → YAML; that forced format migration is no longer
247/// desired. Callers should use [`resolve_project_config_write_path`] / migrate-config-file.
248pub fn maybe_auto_convert_legacy_xbp_json_to_yaml(
249    project_root: &Path,
250    config_path: &Path,
251) -> Result<Option<PathBuf>, String> {
252    // Prefer any existing multi-format config; never force-convert JSON to YAML.
253    if let Some(existing) = find_existing_project_config(project_root) {
254        return Ok(Some(existing));
255    }
256    if config_path.exists() {
257        return Ok(Some(config_path.to_path_buf()));
258    }
259    Ok(None)
260}
261
262/// Canonical published schema URL for project config IntelliSense (yaml-language-server).
263///
264/// Served from **xbp-mirrors** (stable raw URL for consumers outside this monorepo).
265/// Prefer [`xbp_project_schema_directive_for_path`] when writing a known config path so
266/// monorepo checkouts use the local `schemas/xbp.project.schema.json` instead.
267pub const XBP_PROJECT_YAML_SCHEMA_URL: &str =
268    "https://raw.githubusercontent.com/xylex-group/xbp-mirrors/refs/heads/main/xbp.project.schema.json";
269
270/// Comment directive understood by the YAML Language Server / VS Code YAML extension.
271/// Prefer [`xbp_project_schema_directive_for_path`] when writing a known config path so
272/// monorepo checkouts use the local `schemas/xbp.project.schema.json`.
273pub const XBP_PROJECT_YAML_SCHEMA_DIRECTIVE: &str =
274    "# yaml-language-server: $schema=https://raw.githubusercontent.com/xylex-group/xbp-mirrors/refs/heads/main/xbp.project.schema.json\n";
275
276pub fn serialize_xbp_yaml<T: Serialize>(value: &T) -> Result<String, String> {
277    serialize_xbp_yaml_for_path(value, None)
278}
279
280/// Serialize YAML and attach the best schema directive for `config_path` (local file when
281/// `schemas/xbp.project.schema.json` exists in the project, else the published URL).
282pub fn serialize_xbp_yaml_for_path<T: Serialize>(
283    value: &T,
284    config_path: Option<&Path>,
285) -> Result<String, String> {
286    // Route through JSON Value so we can force services[].name (etc.) key order
287    // before YAML emit. Typed structs already put `name` first; Value maps do not.
288    let mut json = serde_json::to_value(value)
289        .map_err(|e| format!("Failed to prepare YAML config: {}", e))?;
290    let _ = ensure_named_entry_key_order(&mut json);
291    let yaml = serde_yaml::to_string(&json)
292        .map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
293    let body = normalize_xbp_yaml_environment_quotes(&yaml);
294    let directive = config_path
295        .map(xbp_project_schema_directive_for_path)
296        .unwrap_or_else(|| XBP_PROJECT_YAML_SCHEMA_DIRECTIVE.to_string());
297    Ok(ensure_xbp_yaml_schema_directive_with(&body, &directive))
298}
299
300/// True when `path` is an XBP project config filename (any supported format).
301pub fn is_xbp_project_config_filename(path: &str) -> bool {
302    let normalized = path.replace('\\', "/");
303    let file = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
304    PROJECT_CONFIG_FILENAMES
305        .iter()
306        .any(|name| file.eq_ignore_ascii_case(name))
307}
308
309/// Build a `# yaml-language-server: $schema=...` line for a config file path.
310///
311/// When the project contains `schemas/xbp.project.schema.json`, emit a **relative**
312/// path so editors use the local (current) schema instead of a stale remote/mirror.
313pub fn xbp_project_schema_directive_for_path(config_path: &Path) -> String {
314    let project_root = config_path
315        .parent()
316        .and_then(|p| {
317            if p.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
318                p.parent()
319            } else {
320                Some(p)
321            }
322        })
323        .map(Path::to_path_buf);
324
325    if let Some(root) = project_root {
326        let local_schema = root.join("schemas").join("xbp.project.schema.json");
327        if local_schema.is_file() {
328            // Relative to the config file (yaml-language-server resolves vs the document).
329            if let Ok(rel) = pathdiff_relative(config_path.parent().unwrap_or(config_path), &local_schema)
330            {
331                let rel = rel.replace('\\', "/");
332                return format!("# yaml-language-server: $schema={rel}\n");
333            }
334            // Fallback: path relative to project root from `.xbp/`
335            if config_path
336                .parent()
337                .and_then(|p| p.file_name())
338                == Some(std::ffi::OsStr::new(".xbp"))
339            {
340                return "# yaml-language-server: $schema=../schemas/xbp.project.schema.json\n"
341                    .to_string();
342            }
343            return "# yaml-language-server: $schema=./schemas/xbp.project.schema.json\n"
344                .to_string();
345        }
346    }
347
348    XBP_PROJECT_YAML_SCHEMA_DIRECTIVE.to_string()
349}
350
351/// Minimal relative path helper (no new dependency). Returns `from` → `to` using `../` segments.
352fn pathdiff_relative(from_dir: &Path, to_file: &Path) -> Result<String, ()> {
353    let from = fs::canonicalize(from_dir).map_err(|_| ())?;
354    let to = fs::canonicalize(to_file).map_err(|_| ())?;
355    let mut from_components: Vec<_> = from.components().collect();
356    let mut to_components: Vec<_> = to.components().collect();
357    // Drop common prefix
358    while !from_components.is_empty()
359        && !to_components.is_empty()
360        && from_components[0] == to_components[0]
361    {
362        from_components.remove(0);
363        to_components.remove(0);
364    }
365    let mut parts: Vec<String> = from_components.iter().map(|_| "..".to_string()).collect();
366    for c in to_components {
367        parts.push(c.as_os_str().to_string_lossy().into_owned());
368    }
369    if parts.is_empty() {
370        return Err(());
371    }
372    Ok(parts.join("/"))
373}
374
375/// Ensure the yaml-language-server `$schema` comment is present (published URL).
376pub fn ensure_xbp_yaml_schema_directive(yaml: &str) -> String {
377    ensure_xbp_yaml_schema_directive_with(yaml, XBP_PROJECT_YAML_SCHEMA_DIRECTIVE)
378}
379
380/// Ensure / **rewrite** the `$schema` directive to `directive` (replaces any existing header).
381pub fn ensure_xbp_yaml_schema_directive_with(yaml: &str, directive: &str) -> String {
382    let directive = if directive.ends_with('\n') {
383        directive.to_string()
384    } else {
385        format!("{directive}\n")
386    };
387    let trimmed = yaml.trim_start_matches('\u{feff}');
388
389    // Strip existing yaml-language-server schema lines from the header.
390    let lines: Vec<&str> = trimmed.lines().collect();
391    let mut body_start = 0usize;
392    if lines.first().is_some_and(|l| l.trim() == "---") {
393        body_start = 1;
394    }
395    while body_start < lines.len() {
396        let line = lines[body_start].trim();
397        if line.is_empty() {
398            body_start += 1;
399            continue;
400        }
401        if line.contains("yaml-language-server") && line.contains("$schema") {
402            body_start += 1;
403            continue;
404        }
405        break;
406    }
407    let body = lines[body_start..].join("\n");
408    let body = if trimmed.ends_with('\n') && !body.ends_with('\n') {
409        format!("{body}\n")
410    } else {
411        body
412    };
413
414    if lines.first().is_some_and(|l| l.trim() == "---") {
415        format!("---\n{directive}{body}")
416    } else {
417        format!("{directive}{body}")
418    }
419}
420
421fn normalize_xbp_yaml_environment_quotes(yaml: &str) -> String {
422    let mut rendered = String::with_capacity(yaml.len());
423    let mut environment_indent: Option<usize> = None;
424
425    for raw_line in yaml.split_inclusive('\n') {
426        let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
427        let newline = if raw_line.ends_with('\n') { "\n" } else { "" };
428        let indent = line
429            .chars()
430            .take_while(|character| *character == ' ')
431            .count();
432
433        if let Some(active_indent) = environment_indent {
434            if !line.trim().is_empty() && indent <= active_indent {
435                environment_indent = None;
436            }
437        }
438
439        if line.trim() == "environment:" {
440            environment_indent = Some(indent);
441            rendered.push_str(line);
442            rendered.push_str(newline);
443            continue;
444        }
445
446        if let Some(active_indent) = environment_indent {
447            if !line.trim().is_empty() && indent > active_indent {
448                if let Some(rewritten) = rewrite_environment_scalar_line(line) {
449                    rendered.push_str(&rewritten);
450                    rendered.push_str(newline);
451                    continue;
452                }
453            }
454        }
455
456        rendered.push_str(line);
457        rendered.push_str(newline);
458    }
459
460    rendered
461}
462
463fn rewrite_environment_scalar_line(line: &str) -> Option<String> {
464    let (prefix, raw_value) = line.split_once(':')?;
465    let value_fragment = raw_value.trim_start();
466    if value_fragment.is_empty() {
467        return Some(format!("{prefix}: \"\""));
468    }
469
470    let parsed =
471        serde_yaml::from_str::<serde_yaml::Value>(&format!("value: {value_fragment}\n")).ok()?;
472    let value = match parsed {
473        serde_yaml::Value::Mapping(mut mapping) => {
474            mapping.remove(serde_yaml::Value::String("value".to_string()))?
475        }
476        _ => return None,
477    };
478
479    let text = match value {
480        serde_yaml::Value::String(text) => text,
481        serde_yaml::Value::Bool(value) => value.to_string(),
482        serde_yaml::Value::Number(value) => value.to_string(),
483        serde_yaml::Value::Null => String::new(),
484        _ => return None,
485    };
486
487    Some(format!(
488        "{prefix}: {}",
489        render_yaml_environment_scalar(&text)
490    ))
491}
492
493fn render_yaml_environment_scalar(value: &str) -> String {
494    if value.is_empty() {
495        return "\"\"".to_string();
496    }
497
498    let contains_control = value
499        .chars()
500        .any(|character| matches!(character, '\n' | '\r' | '\t') || character.is_control());
501
502    if !contains_control && value.contains('"') {
503        let escaped = value.replace('\'', "''");
504        format!("'{escaped}'")
505    } else {
506        format!("\"{}\"", escape_yaml_double_quoted(value))
507    }
508}
509
510fn escape_yaml_double_quoted(value: &str) -> String {
511    let mut escaped = String::with_capacity(value.len());
512
513    for character in value.chars() {
514        match character {
515            '\\' => escaped.push_str("\\\\"),
516            '"' => escaped.push_str("\\\""),
517            '\n' => escaped.push_str("\\n"),
518            '\r' => escaped.push_str("\\r"),
519            '\t' => escaped.push_str("\\t"),
520            character if character.is_control() => {
521                use std::fmt::Write as _;
522                let _ = write!(&mut escaped, "\\u{:04x}", character as u32);
523            }
524            character => escaped.push(character),
525        }
526    }
527
528    escaped
529}
530
531pub fn write_json_config_from_any_xbp_config(
532    config_path: &Path,
533    output_json_path: &Path,
534) -> Result<(), String> {
535    let content = fs::read_to_string(config_path)
536        .map_err(|e| format!("Failed to read config {}: {}", config_path.display(), e))?;
537
538    let kind = config_kind_from_path(config_path)?;
539    let (value, _) = parse_config_with_auto_heal::<Value>(&content, kind)
540        .map_err(|e| format!("Failed to parse config: {}", e))?;
541
542    if let Some(parent) = output_json_path.parent() {
543        fs::create_dir_all(parent).map_err(|e| {
544            format!(
545                "Failed to create config directory {}: {}",
546                parent.display(),
547                e
548            )
549        })?;
550    }
551
552    let rendered = serde_json::to_string_pretty(&value)
553        .map_err(|e| format!("Failed to serialize JSON config: {}", e))?;
554    fs::write(output_json_path, rendered).map_err(|e| {
555        format!(
556            "Failed to write JSON config {}: {}",
557            output_json_path.display(),
558            e
559        )
560    })?;
561
562    Ok(())
563}
564
565pub fn find_xbp_config_upwards(start_dir: &Path) -> Option<FoundXbpConfig> {
566    find_xbp_config_at_or_above(start_dir)
567}
568
569/// Nearest project config at `dir` only (does not walk ancestors).
570pub fn find_xbp_config_in_dir(dir: &Path) -> Option<FoundXbpConfig> {
571    for (path, kind) in project_config_candidates(dir) {
572        if !path.exists() {
573            continue;
574        }
575        let project_root = path
576            .parent()
577            .and_then(|p| {
578                if p.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
579                    p.parent().map(|pp| pp.to_path_buf())
580                } else {
581                    Some(p.to_path_buf())
582                }
583            })
584            .unwrap_or_else(|| dir.to_path_buf());
585
586        let location = path
587            .strip_prefix(&project_root)
588            .ok()
589            .map(|p| p.to_string_lossy().replace('\\', "/"))
590            .unwrap_or_else(|| path.to_string_lossy().replace('\\', "/"));
591
592        return Some(FoundXbpConfig {
593            project_root,
594            config_path: path,
595            kind,
596            location,
597        });
598    }
599    None
600}
601
602fn find_xbp_config_at_or_above(start_dir: &Path) -> Option<FoundXbpConfig> {
603    for dir in start_dir.ancestors() {
604        if let Some(found) = find_xbp_config_in_dir(dir) {
605            return Some(found);
606        }
607    }
608    None
609}
610
611/// Repository-bound config: prefer git root `.xbp`, else outermost ancestor with a config.
612///
613/// Nested package configs (`services/foo/.xbp/xbp.toml`) are **not** treated as the
614/// monorepo root when a parent (repository) config exists. Use this for cloudflare /
615/// workers / deploy so nested packages absorb into the global project file.
616pub fn find_repository_xbp_config(start_dir: &Path) -> Option<FoundXbpConfig> {
617    let mut chain: Vec<FoundXbpConfig> = Vec::new();
618    for dir in start_dir.ancestors() {
619        if let Some(found) = find_xbp_config_in_dir(dir) {
620            // Avoid duplicates when both `.xbp/xbp.toml` and root `xbp.yaml` exist.
621            if chain
622                .iter()
623                .any(|c| c.config_path == found.config_path)
624            {
625                continue;
626            }
627            chain.push(found);
628        }
629    }
630    if chain.is_empty() {
631        return None;
632    }
633
634    // Prefer config whose project_root is a git worktree root.
635    for found in &chain {
636        if found.project_root.join(".git").exists() {
637            return Some(found.clone());
638        }
639    }
640
641    // Outermost ancestor config (last in walk = closest to filesystem root).
642    chain.pop()
643}
644
645/// Collect nested package `.xbp/xbp.*` paths under a repository root (excludes the root config).
646pub fn list_nested_package_xbp_config_paths(repo_root: &Path) -> Result<Vec<PathBuf>, String> {
647    let mut nested_paths: Vec<PathBuf> = Vec::new();
648    collect_nested_xbp_config_paths(repo_root, repo_root, &mut nested_paths)?;
649    nested_paths.sort();
650    nested_paths.dedup();
651    Ok(nested_paths
652        .into_iter()
653        .filter(|path| {
654            // Keep only `<pkg>/.xbp/xbp.*` where pkg is not repo_root.
655            let Some(xbp_dir) = path.parent() else {
656                return false;
657            };
658            if xbp_dir.file_name().and_then(|n| n.to_str()) != Some(".xbp") {
659                return false;
660            }
661            let Some(package_root) = xbp_dir.parent() else {
662                return false;
663            };
664            let ca = fs::canonicalize(package_root).unwrap_or_else(|_| package_root.to_path_buf());
665            let cb = fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
666            ca != cb
667        })
668        .collect())
669}
670
671pub fn collect_known_xbp_projects() -> Vec<KnownXbpProject> {
672    let mut projects = Vec::new();
673    let mut seen_roots = HashSet::new();
674
675    for project in find_all_xbp_projects() {
676        let root = canonicalize_or_fallback(&project.path);
677        if seen_roots.insert(root.clone()) {
678            projects.push(KnownXbpProject {
679                root,
680                name: project.name,
681            });
682        }
683    }
684
685    if let Ok(current_dir) = std::env::current_dir() {
686        if let Some(found) = find_xbp_config_upwards(&current_dir) {
687            let root = canonicalize_or_fallback(&found.project_root);
688            if seen_roots.insert(root.clone()) {
689                let name = root
690                    .file_name()
691                    .and_then(|value| value.to_str())
692                    .unwrap_or("current")
693                    .to_string();
694                projects.push(KnownXbpProject { root, name });
695            }
696        }
697    }
698
699    projects.sort_by(|left, right| {
700        right
701            .root
702            .components()
703            .count()
704            .cmp(&left.root.components().count())
705            .then_with(|| left.name.cmp(&right.name))
706    });
707    projects
708}
709
710pub fn resolve_xbp_project_for_path(
711    candidate: &Path,
712    known_projects: &[KnownXbpProject],
713) -> Option<String> {
714    if candidate.as_os_str().is_empty() {
715        return None;
716    }
717
718    if let Some(found) = find_xbp_config_upwards(candidate) {
719        let found_root = canonicalize_or_fallback(&found.project_root);
720        if let Some(project) = known_projects
721            .iter()
722            .find(|project| canonicalize_or_fallback(&project.root) == found_root)
723        {
724            return Some(project.name.clone());
725        }
726
727        if known_projects.is_empty() {
728            return found_root
729                .file_name()
730                .and_then(|value| value.to_str())
731                .map(|value| value.to_string());
732        }
733    }
734
735    let candidate_path = canonicalize_or_fallback(candidate);
736    known_projects
737        .iter()
738        .find(|project| candidate_path.starts_with(canonicalize_or_fallback(&project.root)))
739        .map(|project| project.name.clone())
740}
741
742pub fn collect_listening_port_ownership() -> Result<BTreeMap<u16, ListeningPortOwnership>, String> {
743    use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
744
745    let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
746    let proto_flags = ProtocolFlags::TCP;
747    let sockets = get_sockets_info(af_flags, proto_flags)
748        .map_err(|e| format!("Failed to get sockets info: {}", e))?;
749
750    let mut system = System::new_all();
751    system.refresh_all();
752    let known_projects = collect_known_xbp_projects();
753    let mut pid_project_cache: HashMap<u32, Option<String>> = HashMap::new();
754    let mut ports: BTreeMap<u16, ListeningPortOwnership> = BTreeMap::new();
755
756    for socket in sockets {
757        if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
758            let state = format!("{:?}", tcp.state);
759            if state != "Listen" && state != "LISTEN" {
760                continue;
761            }
762
763            let row = ports.entry(tcp.local_port).or_default();
764            for pid in socket.associated_pids {
765                row.pids.push(pid);
766                if let Some(project) = pid_project_cache
767                    .entry(pid)
768                    .or_insert_with(|| resolve_xbp_project_for_pid(pid, &system, &known_projects))
769                    .clone()
770                {
771                    row.xbp_projects.push(project);
772                }
773            }
774        }
775    }
776
777    for row in ports.values_mut() {
778        row.pids.sort_unstable();
779        row.pids.dedup();
780        row.xbp_projects.sort();
781        row.xbp_projects.dedup();
782    }
783
784    Ok(ports)
785}
786
787fn resolve_xbp_project_for_pid(
788    pid: u32,
789    system: &System,
790    known_projects: &[KnownXbpProject],
791) -> Option<String> {
792    let process = system.process(Pid::from_u32(pid))?;
793
794    if let Some(project) = process
795        .cwd()
796        .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
797    {
798        return Some(project);
799    }
800
801    process
802        .exe()
803        .and_then(|path| path.parent())
804        .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
805}
806
807fn canonicalize_or_fallback(path: &Path) -> PathBuf {
808    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
809}
810
811pub fn expand_home_in_string(input: &str) -> String {
812    let home = dirs::home_dir()
813        .unwrap_or_else(|| std::path::PathBuf::from("."))
814        .to_string_lossy()
815        .to_string();
816
817    if input == "~" {
818        return home;
819    }
820
821    if let Some(rest) = input
822        .strip_prefix("~/")
823        .or_else(|| input.strip_prefix("~\\"))
824    {
825        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
826    }
827
828    if let Some(rest) = input
829        .strip_prefix("$HOME/")
830        .or_else(|| input.strip_prefix("$HOME\\"))
831    {
832        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
833    }
834
835    if let Some(rest) = input
836        .strip_prefix("${HOME}/")
837        .or_else(|| input.strip_prefix("${HOME}\\"))
838    {
839        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
840    }
841
842    input.to_string()
843}
844
845pub fn collapse_home_to_env(input: &str) -> String {
846    let home = dirs::home_dir()
847        .unwrap_or_else(|| std::path::PathBuf::from("."))
848        .to_string_lossy()
849        .to_string();
850
851    if input == home {
852        return "$HOME".to_string();
853    }
854
855    if let Some(rest) = input.strip_prefix(&(home.clone() + "/")) {
856        return format!("$HOME/{}", rest);
857    }
858
859    if let Some(rest) = input.strip_prefix(&(home.clone() + "\\")) {
860        return format!("$HOME\\{}", rest);
861    }
862
863    input.to_string()
864}
865
866pub fn cargo_program() -> PathBuf {
867    std::env::var_os("CARGO")
868        .map(PathBuf::from)
869        .unwrap_or_else(|| PathBuf::from("cargo"))
870}
871
872pub fn cargo_command_exists() -> bool {
873    let program = cargo_program();
874    if program.is_absolute() {
875        return program.is_file();
876    }
877    command_exists(program.to_string_lossy().as_ref())
878}
879
880pub fn command_exists(program: &str) -> bool {
881    let Some(path_var) = std::env::var_os("PATH") else {
882        return false;
883    };
884
885    for dir in std::env::split_paths(&path_var) {
886        let candidate = dir.join(program);
887        if candidate.is_file() {
888            return true;
889        }
890
891        #[cfg(windows)]
892        for ext in ["exe", "cmd", "bat"] {
893            let candidate = dir.join(format!("{}.{}", program, ext));
894            if candidate.is_file() {
895                return true;
896            }
897        }
898    }
899
900    false
901}
902
903pub fn git_remote_url_from_metadata(
904    project_root: &Path,
905    remote: &str,
906) -> Result<Option<String>, String> {
907    let Some(git_dir) = resolve_git_dir(project_root)? else {
908        return Ok(None);
909    };
910
911    let config_path = git_dir.join("config");
912    if !config_path.exists() {
913        return Ok(None);
914    }
915
916    let content = fs::read_to_string(&config_path)
917        .map_err(|e| format!("Failed to read git config {}: {}", config_path.display(), e))?;
918
919    Ok(parse_git_remote_url_from_config(&content, remote))
920}
921
922pub fn parse_github_repo_from_remote_url(url: &str) -> Option<(String, String)> {
923    let normalized: &str = url.trim();
924
925    let repo_path: String = if let Some(path) = normalized.strip_prefix("git@github.com:") {
926        path.to_string()
927    } else if let Some(path) = parse_github_https_repo_path(normalized) {
928        path
929    } else if let Some(path) = normalized.strip_prefix("ssh://git@github.com/") {
930        path.to_string()
931    } else {
932        return None;
933    };
934
935    let cleaned: &str = repo_path.trim_end_matches('/').trim_end_matches(".git");
936    let mut segments: std::str::Split<'_, char> = cleaned.split('/');
937    let owner: &str = segments.next()?.trim();
938    let repo: &str = segments.next()?.trim();
939    if owner.is_empty() || repo.is_empty() || segments.next().is_some() {
940        return None;
941    }
942
943    Some((owner.to_string(), repo.to_string()))
944}
945
946pub fn redact_remote_url_credentials(url: &str) -> String {
947    if !url.contains('@') || !url.contains("://") {
948        return url.to_string();
949    }
950    let mut parsed: reqwest::Url = match reqwest::Url::parse(url) {
951        Ok(value) => value,
952        Err(_) => return url.to_string(),
953    };
954    if parsed.password().is_some() {
955        let _ = parsed.set_password(Some("REDACTED"));
956    }
957    if !parsed.username().is_empty() {
958        let _ = parsed.set_username("REDACTED");
959    }
960    parsed.to_string()
961}
962
963fn resolve_git_dir(project_root: &Path) -> Result<Option<PathBuf>, String> {
964    // Walk ancestors so package/subfolder project roots (e.g. monorepo packages with
965    // their own `.xbp/xbp.yaml`) still resolve the enclosing repository's remotes.
966    for dir in project_root.ancestors() {
967        if let Some(git_dir) = resolve_git_dir_at(dir)? {
968            return Ok(Some(git_dir));
969        }
970    }
971    Ok(None)
972}
973
974fn resolve_git_dir_at(dir: &Path) -> Result<Option<PathBuf>, String> {
975    let dot_git = dir.join(".git");
976    if dot_git.is_dir() {
977        return Ok(Some(dot_git));
978    }
979
980    if !dot_git.exists() {
981        return Ok(None);
982    }
983
984    let content = fs::read_to_string(&dot_git)
985        .map_err(|e| format!("Failed to read git metadata {}: {}", dot_git.display(), e))?;
986    let git_dir = content
987        .lines()
988        .find_map(|line| line.trim().strip_prefix("gitdir:").map(str::trim))
989        .filter(|value| !value.is_empty())
990        .ok_or_else(|| format!("Failed to parse gitdir pointer from {}", dot_git.display()))?;
991
992    let git_dir_path = PathBuf::from(git_dir);
993    let resolved = if git_dir_path.is_absolute() {
994        git_dir_path
995    } else {
996        dot_git.parent().unwrap_or(dir).join(git_dir_path)
997    };
998
999    Ok(Some(resolved))
1000}
1001
1002fn parse_git_remote_url_from_config(content: &str, remote: &str) -> Option<String> {
1003    let expected_quoted = format!(r#"remote "{}""#, remote);
1004    let expected_dotted = format!("remote.{}", remote);
1005    let mut in_target_section = false;
1006
1007    for line in content.lines() {
1008        let trimmed = line.trim();
1009        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
1010            continue;
1011        }
1012
1013        if trimmed.starts_with('[') && trimmed.ends_with(']') {
1014            let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
1015            in_target_section = section.eq_ignore_ascii_case(&expected_quoted)
1016                || section.eq_ignore_ascii_case(&expected_dotted);
1017            continue;
1018        }
1019
1020        if !in_target_section {
1021            continue;
1022        }
1023
1024        let Some((key, value)) = trimmed.split_once('=') else {
1025            continue;
1026        };
1027        if key.trim().eq_ignore_ascii_case("url") {
1028            let value = value.trim();
1029            if !value.is_empty() {
1030                return Some(value.to_string());
1031            }
1032        }
1033    }
1034
1035    None
1036}
1037
1038fn parse_github_https_repo_path(url: &str) -> Option<String> {
1039    let parsed: reqwest::Url = reqwest::Url::parse(url).ok()?;
1040    if !matches!(parsed.scheme(), "http" | "https") {
1041        return None;
1042    }
1043    if parsed.host_str()?.eq_ignore_ascii_case("github.com") {
1044        return Some(parsed.path().trim_start_matches('/').to_string());
1045    }
1046    None
1047}
1048
1049pub fn first_available_command(candidates: &[&str]) -> Option<String> {
1050    candidates
1051        .iter()
1052        .find(|candidate| command_exists(candidate))
1053        .map(|candidate| (*candidate).to_string())
1054}
1055
1056pub fn preferred_python_command() -> String {
1057    first_available_command(&["python3", "python"]).unwrap_or_else(|| {
1058        if cfg!(target_os = "windows") {
1059            "python".to_string()
1060        } else {
1061            "python3".to_string()
1062        }
1063    })
1064}
1065
1066pub fn preferred_pip_command() -> String {
1067    first_available_command(&["pip3", "pip"]).unwrap_or_else(|| {
1068        if cfg!(target_os = "windows") {
1069            "pip".to_string()
1070        } else {
1071            "pip3".to_string()
1072        }
1073    })
1074}
1075
1076pub fn open_with_default_handler(target: &str) -> Result<(), String> {
1077    let mut command = if cfg!(target_os = "windows") {
1078        let mut cmd = Command::new("cmd");
1079        cmd.arg("/C").arg("start").arg("").arg(target);
1080        cmd
1081    } else if cfg!(target_os = "macos") {
1082        let mut cmd = Command::new("open");
1083        cmd.arg(target);
1084        cmd
1085    } else {
1086        let mut cmd = Command::new("xdg-open");
1087        cmd.arg(target);
1088        cmd
1089    };
1090
1091    command
1092        .spawn()
1093        .map_err(|e| format!("Failed to open '{}': {}", target, e))?;
1094    Ok(())
1095}
1096
1097pub fn open_path_with_editor(path: &Path) -> Result<(), String> {
1098    if let Ok(editor) = std::env::var("EDITOR") {
1099        let mut parts = editor.split_whitespace();
1100        let binary = parts
1101            .next()
1102            .ok_or_else(|| "EDITOR is set but empty".to_string())?;
1103        let mut command = Command::new(binary);
1104        for part in parts {
1105            command.arg(part);
1106        }
1107        command
1108            .arg(path)
1109            .spawn()
1110            .map_err(|e| format!("Failed to launch editor '{}': {}", editor, e))?;
1111        return Ok(());
1112    }
1113
1114    open_with_default_handler(&path.display().to_string())
1115}
1116
1117/// Metadata about structural YAML/JSON fixes applied before field-level auto-heal.
1118#[derive(Debug, Clone, Default, PartialEq, Eq)]
1119struct ConfigParseNormalizeMeta {
1120    /// Leading UTF-8 BOM was present and stripped.
1121    had_bom: bool,
1122    /// File contained more than one YAML document; documents were merged.
1123    multi_document: bool,
1124    /// Duplicate top-level keys were resolved (richer block wins).
1125    duplicate_top_level_keys: bool,
1126    /// Duplicate nested mapping keys (e.g. two `environment:` under one service).
1127    duplicate_nested_keys: bool,
1128    /// Unquoted keys containing `:` (e.g. `deploy:production:`) were quoted.
1129    quoted_colon_keys: bool,
1130    /// Broken `services:` list items were dropped so the document could parse.
1131    salvaged_services: bool,
1132}
1133
1134impl ConfigParseNormalizeMeta {
1135    fn needs_rewrite(&self) -> bool {
1136        self.had_bom
1137            || self.multi_document
1138            || self.duplicate_top_level_keys
1139            || self.duplicate_nested_keys
1140            || self.quoted_colon_keys
1141            || self.salvaged_services
1142    }
1143}
1144
1145/// Resolve duplicate top-level YAML keys that make `serde_yaml` fail (or load
1146/// the wrong half of a corrupted merge).
1147///
1148/// Monorepo generators sometimes emit:
1149/// - a stub `services:` list early, then a full `services:` later
1150/// - a real `openapi:` block, then a trailing `openapi: null` at the wrong indent
1151///
1152/// YAML sequences commonly use `- item` at column 0 under `services:`. A naïve
1153/// "skip duplicate key + indented children" pass incorrectly re-includes those
1154/// list items and leaves the document unparseable.
1155///
1156/// Strategy: split each YAML document into top-level key blocks, pick the best
1157/// block per key (richer sequence/map beats null/stub; non-null beats null),
1158/// then reassemble in first-seen key order.
1159fn dedupe_yaml_top_level_keys(content: &str) -> (String, bool) {
1160    let mut output = String::with_capacity(content.len());
1161    let mut changed = false;
1162    let mut first_document = true;
1163
1164    for document in split_yaml_documents(content) {
1165        if !first_document {
1166            if !output.ends_with('\n') && !output.is_empty() {
1167                output.push('\n');
1168            }
1169            output.push_str("---\n");
1170        }
1171        first_document = false;
1172
1173        let (doc_out, doc_changed) = dedupe_yaml_document_top_level_keys(document);
1174        changed |= doc_changed;
1175        output.push_str(&doc_out);
1176    }
1177
1178    // Preserve a trailing newline when the source had one.
1179    if content.ends_with('\n') && !output.ends_with('\n') {
1180        output.push('\n');
1181    }
1182
1183    (output, changed)
1184}
1185
1186fn split_yaml_documents(content: &str) -> Vec<&str> {
1187    let mut documents = Vec::new();
1188    let mut start = 0usize;
1189    let mut offset = 0usize;
1190    for raw_line in content.split_inclusive('\n') {
1191        let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
1192        let trimmed = line.trim_start();
1193        if offset > start && (trimmed == "---" || trimmed.starts_with("--- ")) {
1194            documents.push(&content[start..offset]);
1195            start = offset + raw_line.len();
1196        }
1197        offset += raw_line.len();
1198    }
1199    documents.push(&content[start..]);
1200    documents
1201}
1202
1203#[derive(Debug, Clone)]
1204struct YamlTopLevelBlock {
1205    key: Option<String>,
1206    text: String,
1207}
1208
1209fn dedupe_yaml_document_top_level_keys(document: &str) -> (String, bool) {
1210    let blocks = split_yaml_top_level_blocks(document);
1211    if blocks.is_empty() {
1212        return (document.to_string(), false);
1213    }
1214
1215    // Preserve leading comments/blank lines before the first key.
1216    let mut leading = String::new();
1217    let mut keyed: Vec<YamlTopLevelBlock> = Vec::new();
1218    let mut trailing_unkeyed = String::new();
1219    let mut seen_key = false;
1220
1221    for block in blocks {
1222        match &block.key {
1223            None if !seen_key => leading.push_str(&block.text),
1224            None => trailing_unkeyed.push_str(&block.text),
1225            Some(_) => {
1226                seen_key = true;
1227                keyed.push(block);
1228            }
1229        }
1230    }
1231
1232    // First-seen key order; replace with a better later occurrence when found.
1233    let mut order: Vec<String> = Vec::new();
1234    let mut chosen: HashMap<String, YamlTopLevelBlock> = HashMap::new();
1235    let mut changed = false;
1236
1237    for block in keyed {
1238        let key = block.key.clone().expect("keyed block");
1239        match chosen.get(&key) {
1240            None => {
1241                order.push(key.clone());
1242                chosen.insert(key, block);
1243            }
1244            Some(existing) => {
1245                changed = true;
1246                if yaml_block_is_better(&key, &block, existing) {
1247                    chosen.insert(key, block);
1248                }
1249            }
1250        }
1251    }
1252
1253    if !trailing_unkeyed.is_empty() {
1254        // Orphan top-level list items after a mapping usually mean a botched
1255        // merge; drop them so the document parses.
1256        changed = true;
1257    }
1258
1259    if !changed {
1260        return (document.to_string(), false);
1261    }
1262
1263    let mut output = leading;
1264    for key in order {
1265        if let Some(block) = chosen.remove(&key) {
1266            output.push_str(&block.text);
1267        }
1268    }
1269
1270    (output, true)
1271}
1272
1273fn split_yaml_top_level_blocks(document: &str) -> Vec<YamlTopLevelBlock> {
1274    let mut blocks: Vec<YamlTopLevelBlock> = Vec::new();
1275    let mut current_key: Option<String> = None;
1276    let mut current_text = String::new();
1277
1278    let flush = |key: &mut Option<String>, text: &mut String, blocks: &mut Vec<YamlTopLevelBlock>| {
1279        if text.is_empty() {
1280            return;
1281        }
1282        blocks.push(YamlTopLevelBlock {
1283            key: key.take(),
1284            text: std::mem::take(text),
1285        });
1286    };
1287
1288    for raw_line in document.split_inclusive('\n') {
1289        let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
1290        let trimmed = line.trim_start();
1291        let indent = line.len().saturating_sub(trimmed.len());
1292
1293        let starts_new_key = indent == 0
1294            && !trimmed.is_empty()
1295            && !trimmed.starts_with('#')
1296            && top_level_yaml_key(trimmed).is_some();
1297
1298        if starts_new_key {
1299            flush(&mut current_key, &mut current_text, &mut blocks);
1300            current_key = top_level_yaml_key(trimmed);
1301            current_text.push_str(raw_line);
1302            continue;
1303        }
1304
1305        // Column-0 sequence items belong to the current key (typical `services:` style).
1306        if indent == 0
1307            && !trimmed.is_empty()
1308            && !trimmed.starts_with('#')
1309            && trimmed.starts_with('-')
1310            && current_key.is_some()
1311        {
1312            current_text.push_str(raw_line);
1313            continue;
1314        }
1315
1316        // Comments/blanks/indented content stay with the current block.
1317        if current_key.is_some()
1318            || indent > 0
1319            || trimmed.is_empty()
1320            || trimmed.starts_with('#')
1321        {
1322            current_text.push_str(raw_line);
1323            continue;
1324        }
1325
1326        // Unexpected top-level scalar: start an unkeyed block.
1327        flush(&mut current_key, &mut current_text, &mut blocks);
1328        current_key = None;
1329        current_text.push_str(raw_line);
1330    }
1331
1332    flush(&mut current_key, &mut current_text, &mut blocks);
1333    blocks
1334}
1335
1336fn parse_yaml_top_level_block_value(key: &str, block_text: &str) -> Option<serde_yaml::Value> {
1337    let wrapped = format!("{block_text}");
1338    let value: serde_yaml::Value = serde_yaml::from_str(&wrapped).ok()?;
1339    let mapping = value.as_mapping()?;
1340    mapping
1341        .get(serde_yaml::Value::String(key.to_string()))
1342        .cloned()
1343        .or_else(|| {
1344            // Key may be unquoted / typed differently; scan entries.
1345            mapping.iter().find_map(|(k, v)| {
1346                k.as_str()
1347                    .filter(|candidate| *candidate == key)
1348                    .map(|_| v.clone())
1349            })
1350        })
1351}
1352
1353/// Prefer the block that carries real config over stubs/nulls.
1354fn yaml_block_is_better(key: &str, candidate: &YamlTopLevelBlock, existing: &YamlTopLevelBlock) -> bool {
1355    let candidate_value = parse_yaml_top_level_block_value(key, &candidate.text);
1356    let existing_value = parse_yaml_top_level_block_value(key, &existing.text);
1357
1358    match (candidate_value, existing_value) {
1359        (Some(_), None) => true,
1360        (None, Some(_)) => false,
1361        (None, None) => candidate.text.len() > existing.text.len(),
1362        (Some(cand), Some(exist)) => yaml_value_is_richer(&cand, &exist),
1363    }
1364}
1365
1366fn yaml_value_is_richer(candidate: &serde_yaml::Value, existing: &serde_yaml::Value) -> bool {
1367    use serde_yaml::Value as Y;
1368    match (candidate, existing) {
1369        (Y::Null, _) => false,
1370        (_, Y::Null) => true,
1371        (Y::Sequence(c), Y::Sequence(e)) => {
1372            if c.len() != e.len() {
1373                c.len() > e.len()
1374            } else {
1375                yaml_value_complexity(candidate) > yaml_value_complexity(existing)
1376            }
1377        }
1378        (Y::Mapping(c), Y::Mapping(e)) => {
1379            let c_non_null = c.values().filter(|v| !matches!(v, Y::Null)).count();
1380            let e_non_null = e.values().filter(|v| !matches!(v, Y::Null)).count();
1381            if c_non_null != e_non_null {
1382                c_non_null > e_non_null
1383            } else {
1384                yaml_value_complexity(candidate) >= yaml_value_complexity(existing)
1385            }
1386        }
1387        (Y::Sequence(_), _) => true,
1388        (_, Y::Sequence(_)) => false,
1389        (Y::Mapping(_), _) => true,
1390        (_, Y::Mapping(_)) => false,
1391        _ => yaml_value_complexity(candidate) >= yaml_value_complexity(existing),
1392    }
1393}
1394
1395fn yaml_value_complexity(value: &serde_yaml::Value) -> usize {
1396    use serde_yaml::Value as Y;
1397    match value {
1398        Y::Null => 0,
1399        Y::Bool(_) | Y::Number(_) | Y::String(_) => 1,
1400        Y::Sequence(items) => 1 + items.iter().map(yaml_value_complexity).sum::<usize>(),
1401        Y::Mapping(map) => {
1402            1 + map
1403                .iter()
1404                .map(|(k, v)| yaml_value_complexity(k) + yaml_value_complexity(v))
1405                .sum::<usize>()
1406        }
1407        Y::Tagged(tagged) => 1 + yaml_value_complexity(&tagged.value),
1408    }
1409}
1410
1411fn top_level_yaml_key(trimmed_line: &str) -> Option<String> {
1412    if trimmed_line.starts_with('-') {
1413        return None;
1414    }
1415    yaml_mapping_key(trimmed_line)
1416}
1417
1418/// Parse a mapping key from a trimmed line (`key:` / `key: value`). Returns None for
1419/// list markers, comments, or non-key lines.
1420fn yaml_mapping_key(trimmed_line: &str) -> Option<String> {
1421    let without_comment = trimmed_line
1422        .split_once(" #")
1423        .map(|(left, _)| left)
1424        .unwrap_or(trimmed_line)
1425        .trim_end();
1426    let (key, rest) = without_comment.split_once(':')?;
1427    let key = key.trim();
1428    if key.is_empty() || key.contains(' ') || key.contains('\t') {
1429        return None;
1430    }
1431    // Quoted keys: strip surrounding quotes for identity comparison.
1432    let key = key
1433        .strip_prefix('"')
1434        .and_then(|k| k.strip_suffix('"'))
1435        .or_else(|| {
1436            key.strip_prefix('\'')
1437                .and_then(|k| k.strip_suffix('\''))
1438        })
1439        .unwrap_or(key);
1440    // `http://...` style bare strings are not keys.
1441    if rest.starts_with("//") {
1442        return None;
1443    }
1444    Some(key.to_string())
1445}
1446
1447fn line_indent(line: &str) -> usize {
1448    let trimmed = line.trim_start_matches(|c: char| c == ' ' || c == '\t');
1449    line.len().saturating_sub(trimmed.len())
1450}
1451
1452fn line_content(raw: &str) -> &str {
1453    raw.strip_suffix('\n').unwrap_or(raw)
1454}
1455
1456/// Resolve nested duplicate mapping keys that make `serde_yaml` fail with:
1457/// `services[0]: duplicate entry with key "environment" at line N`.
1458///
1459/// Strategy: iteratively parse; on a duplicate-key error, merge the two sibling
1460/// blocks for that key (deep-merge maps; later scalars win) and retry.
1461fn dedupe_yaml_nested_mapping_keys(content: &str) -> (String, bool) {
1462    let mut current = content.to_string();
1463    let mut changed = false;
1464
1465    for _ in 0..64 {
1466        match serde_yaml::from_str::<serde_yaml::Value>(&current) {
1467            Ok(_) => return (current, changed),
1468            Err(error) => {
1469                let message = error.to_string();
1470                if !message.contains("duplicate entry with key") {
1471                    return (current, changed);
1472                }
1473                match heal_one_nested_duplicate_mapping_key(&current, &message) {
1474                    Some(next) if next != current => {
1475                        current = next;
1476                        changed = true;
1477                    }
1478                    _ => return (current, changed),
1479                }
1480            }
1481        }
1482    }
1483
1484    (current, changed)
1485}
1486
1487fn extract_duplicate_key_error(message: &str) -> Option<(String, usize)> {
1488    // `... duplicate entry with key "environment" at line 86 column 3`
1489    let key = {
1490        let marker = "duplicate entry with key \"";
1491        let start = message.find(marker)? + marker.len();
1492        let end = message[start..].find('"')? + start;
1493        message[start..end].to_string()
1494    };
1495    let line = {
1496        let marker = "at line ";
1497        let start = message.find(marker)? + marker.len();
1498        let digits: String = message[start..]
1499            .chars()
1500            .take_while(|c| c.is_ascii_digit())
1501            .collect();
1502        digits.parse::<usize>().ok()?
1503    };
1504    if line == 0 {
1505        return None;
1506    }
1507    Some((key, line))
1508}
1509
1510/// Pure mapping key line (`  environment:`), not a sequence item (`- name: app`).
1511fn pure_mapping_key_indent_and_name(line: &str) -> Option<(usize, String)> {
1512    let content = line_content(line);
1513    let trimmed = content.trim_start();
1514    if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
1515        return None;
1516    }
1517    let indent = line_indent(content);
1518    let key = yaml_mapping_key(trimmed)?;
1519    Some((indent, key))
1520}
1521
1522/// Inclusive start / exclusive end of a mapping key block at `key_indent`.
1523fn yaml_mapping_key_block_range(
1524    lines: &[String],
1525    key_line_idx: usize,
1526    key_indent: usize,
1527) -> (usize, usize) {
1528    let mut end = key_line_idx + 1;
1529    while end < lines.len() {
1530        let line = line_content(&lines[end]);
1531        let trimmed = line.trim_start();
1532        if trimmed.is_empty() || trimmed.starts_with('#') {
1533            let mut look = end + 1;
1534            while look < lines.len() {
1535                let l = line_content(&lines[look]);
1536                let t = l.trim_start();
1537                if t.is_empty() || t.starts_with('#') {
1538                    look += 1;
1539                    continue;
1540                }
1541                let i = line_indent(l);
1542                if i > key_indent {
1543                    break;
1544                }
1545                return (key_line_idx, end);
1546            }
1547            end += 1;
1548            continue;
1549        }
1550        let indent = line_indent(line);
1551        if indent > key_indent {
1552            end += 1;
1553            continue;
1554        }
1555        break;
1556    }
1557    (key_line_idx, end)
1558}
1559
1560/// True when every real line between `first_idx` and `second_idx` stays inside
1561/// the same mapping (indent >= `key_indent`). Crossing a shallower indent means
1562/// a different parent (e.g. next service list item).
1563fn same_mapping_scope(
1564    lines: &[String],
1565    first_idx: usize,
1566    second_idx: usize,
1567    key_indent: usize,
1568) -> bool {
1569    if second_idx <= first_idx {
1570        return false;
1571    }
1572    for raw in &lines[first_idx + 1..second_idx] {
1573        let line = line_content(raw);
1574        let trimmed = line.trim_start();
1575        if trimmed.is_empty() || trimmed.starts_with('#') {
1576            continue;
1577        }
1578        if line_indent(line) < key_indent {
1579            return false;
1580        }
1581        // A new sequence item at the same indent as the parent list ends the map.
1582        if line_indent(line) == key_indent.saturating_sub(2) && trimmed.starts_with('-') {
1583            return false;
1584        }
1585    }
1586    true
1587}
1588
1589fn heal_one_nested_duplicate_mapping_key(content: &str, error_message: &str) -> Option<String> {
1590    let (key, _reported_line) = extract_duplicate_key_error(error_message)?;
1591    let lines: Vec<String> = content
1592        .split_inclusive('\n')
1593        .map(|s| s.to_string())
1594        .collect();
1595    if lines.is_empty() {
1596        return None;
1597    }
1598
1599    // serde_yaml line numbers for nested duplicates are often wrong (they can
1600    // point at the sequence item). Scan for the first true sibling pair of `key`.
1601    let mut occurrences: Vec<(usize, usize)> = Vec::new();
1602    for (idx, line) in lines.iter().enumerate() {
1603        if let Some((indent, name)) = pure_mapping_key_indent_and_name(line) {
1604            if name == key {
1605                occurrences.push((idx, indent));
1606            }
1607        }
1608    }
1609
1610    let mut pair: Option<(usize, usize, usize)> = None; // prev_idx, dup_idx, indent
1611    for window in occurrences.windows(2) {
1612        let (i1, ind1) = window[0];
1613        let (i2, ind2) = window[1];
1614        if ind1 != ind2 {
1615            continue;
1616        }
1617        if same_mapping_scope(&lines, i1, i2, ind1) {
1618            pair = Some((i1, i2, ind1));
1619            break;
1620        }
1621    }
1622    let (prev_idx, dup_idx, key_indent) = pair?;
1623
1624    let (prev_start, prev_end) = yaml_mapping_key_block_range(&lines, prev_idx, key_indent);
1625    let (dup_start, dup_end) = yaml_mapping_key_block_range(&lines, dup_idx, key_indent);
1626    if prev_end > dup_start {
1627        return None;
1628    }
1629
1630    let prev_text = lines[prev_start..prev_end].join("");
1631    let dup_text = lines[dup_start..dup_end].join("");
1632    let prev_val = parse_yaml_key_block_value(&key, &prev_text);
1633    let dup_val = parse_yaml_key_block_value(&key, &dup_text);
1634
1635    let merged_value = match (prev_val, dup_val) {
1636        (Some(mut a), Some(b))
1637            if matches!(
1638                (&a, &b),
1639                (serde_yaml::Value::Mapping(_), serde_yaml::Value::Mapping(_))
1640            ) =>
1641        {
1642            deep_merge_yaml(&mut a, b);
1643            a
1644        }
1645        (Some(a), Some(b)) => {
1646            if yaml_value_is_richer(&b, &a) {
1647                b
1648            } else {
1649                a
1650            }
1651        }
1652        (None, Some(b)) => b,
1653        (Some(a), None) => a,
1654        (None, None) => return None,
1655    };
1656
1657    let indent_str = " ".repeat(key_indent);
1658    let mut map = serde_yaml::Mapping::new();
1659    map.insert(serde_yaml::Value::String(key.clone()), merged_value);
1660    let serialized = serde_yaml::to_string(&serde_yaml::Value::Mapping(map)).ok()?;
1661    let mut rendered = String::new();
1662    for ser_line in serialized.lines() {
1663        if ser_line.trim().is_empty() {
1664            continue;
1665        }
1666        rendered.push_str(&indent_str);
1667        rendered.push_str(ser_line);
1668        rendered.push('\n');
1669    }
1670    if rendered.is_empty() {
1671        return None;
1672    }
1673
1674    let mut output = String::with_capacity(content.len());
1675    for line in &lines[..prev_start] {
1676        output.push_str(line);
1677    }
1678    output.push_str(&rendered);
1679    for line in &lines[prev_end..dup_start] {
1680        output.push_str(line);
1681    }
1682    for line in &lines[dup_end..] {
1683        output.push_str(line);
1684    }
1685    Some(output)
1686}
1687
1688fn parse_yaml_key_block_value(key: &str, block_text: &str) -> Option<serde_yaml::Value> {
1689    let value: serde_yaml::Value = serde_yaml::from_str(block_text).ok()?;
1690    let mapping = value.as_mapping()?;
1691    mapping
1692        .get(serde_yaml::Value::String(key.to_string()))
1693        .cloned()
1694        .or_else(|| {
1695            mapping.iter().find_map(|(k, v)| {
1696                k.as_str()
1697                    .filter(|candidate| *candidate == key)
1698                    .map(|_| v.clone())
1699            })
1700        })
1701}
1702
1703/// Parse YAML that may include a UTF-8 BOM and/or multiple documents.
1704///
1705/// Windows editors often write a BOM; `serde_yaml::from_str` then fails with
1706/// "deserializing from YAML containing more than one document is not supported".
1707/// Multi-document streams (split by `---`) are deep-merged into one mapping so
1708/// legacy/split configs still load. Callers rewrite a single clean document.
1709///
1710/// Structural auto-heal before parse:
1711/// - quote unquoted keys that contain `:` (script names like `deploy:production`)
1712/// - resolve duplicate top-level keys (richer block wins; stub `services:` loses)
1713/// - resolve duplicate nested mapping keys (e.g. two `environment:` under a service)
1714/// - if the document still fails, drop unparseable `services:` list items
1715fn parse_yaml_config_documents(
1716    content: &str,
1717) -> Result<(serde_yaml::Value, ConfigParseNormalizeMeta), String> {
1718    let had_bom = content.starts_with('\u{feff}');
1719    let stripped = strip_utf8_bom(content);
1720    let (quoted, quoted_colon_keys) = quote_unquoted_yaml_keys_with_colons(stripped);
1721    let (top_deduped, duplicate_top_level_keys) = dedupe_yaml_top_level_keys(&quoted);
1722    let (deduped, duplicate_nested_keys) = dedupe_yaml_nested_mapping_keys(&top_deduped);
1723
1724    match load_yaml_documents(&deduped) {
1725        Ok(documents) => Ok((
1726            finalize_yaml_documents(documents),
1727            ConfigParseNormalizeMeta {
1728                had_bom,
1729                multi_document: documents_len_is_multi(&deduped),
1730                duplicate_top_level_keys,
1731                duplicate_nested_keys,
1732                quoted_colon_keys,
1733                salvaged_services: false,
1734            },
1735        )),
1736        Err(original_error) => {
1737            let (salvaged, salvaged_services) = salvage_yaml_services_sequence(&deduped);
1738            if !salvaged_services {
1739                return Err(original_error);
1740            }
1741            let documents = load_yaml_documents(&salvaged).map_err(|e| {
1742                format!("{original_error}; service salvage also failed: {e}")
1743            })?;
1744            Ok((
1745                finalize_yaml_documents(documents),
1746                ConfigParseNormalizeMeta {
1747                    had_bom,
1748                    multi_document: documents_len_is_multi(&salvaged),
1749                    duplicate_top_level_keys,
1750                    duplicate_nested_keys,
1751                    quoted_colon_keys,
1752                    salvaged_services: true,
1753                },
1754            ))
1755        }
1756    }
1757}
1758
1759fn documents_len_is_multi(content: &str) -> bool {
1760    let mut count = 0usize;
1761    for document in serde_yaml::Deserializer::from_str(content) {
1762        match serde_yaml::Value::deserialize(document) {
1763            Ok(serde_yaml::Value::Null) => {}
1764            Ok(_) => {
1765                count += 1;
1766                if count > 1 {
1767                    return true;
1768                }
1769            }
1770            Err(_) => return false,
1771        }
1772    }
1773    false
1774}
1775
1776fn load_yaml_documents(content: &str) -> Result<Vec<serde_yaml::Value>, String> {
1777    let mut documents = Vec::new();
1778    for document in serde_yaml::Deserializer::from_str(content) {
1779        let value = serde_yaml::Value::deserialize(document).map_err(|e| e.to_string())?;
1780        if !matches!(value, serde_yaml::Value::Null) {
1781            documents.push(value);
1782        }
1783    }
1784    Ok(documents)
1785}
1786
1787fn finalize_yaml_documents(documents: Vec<serde_yaml::Value>) -> serde_yaml::Value {
1788    if documents.is_empty() {
1789        return serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
1790    }
1791    if documents.len() == 1 {
1792        return documents.into_iter().next().unwrap_or(serde_yaml::Value::Null);
1793    }
1794    merge_yaml_values(documents)
1795}
1796
1797/// Quote unquoted mapping keys that contain `:`, e.g.
1798/// `deploy:production: npm run deploy:production` → `"deploy:production": npm run ...`
1799///
1800/// Package script names often use colons; bare YAML treats the first `:` as the
1801/// key/value separator and fails or mis-parses.
1802fn quote_unquoted_yaml_keys_with_colons(content: &str) -> (String, bool) {
1803    let mut output = String::with_capacity(content.len() + 32);
1804    let mut changed = false;
1805
1806    for raw_line in content.split_inclusive('\n') {
1807        let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
1808        let newline = if raw_line.ends_with('\n') { "\n" } else { "" };
1809        let trimmed = line.trim_start();
1810        let indent = line.len().saturating_sub(trimmed.len());
1811
1812        if trimmed.is_empty() || trimmed.starts_with('#') {
1813            output.push_str(line);
1814            output.push_str(newline);
1815            continue;
1816        }
1817
1818        // List item that starts a nested mapping: `- key: value` or `- key:`
1819        let (list_prefix, mapping_body) = if let Some(rest) = trimmed.strip_prefix('-') {
1820            let rest = rest.trim_start();
1821            if rest.is_empty() || rest.starts_with('#') {
1822                output.push_str(line);
1823                output.push_str(newline);
1824                continue;
1825            }
1826            let dash_ws_len = trimmed.len() - rest.len();
1827            (&trimmed[..dash_ws_len], rest)
1828        } else {
1829            ("", trimmed)
1830        };
1831
1832        if mapping_body.starts_with('"') || mapping_body.starts_with('\'') {
1833            output.push_str(line);
1834            output.push_str(newline);
1835            continue;
1836        }
1837
1838        if let Some((key, rest)) = split_yaml_mapping_key(mapping_body) {
1839            if key.contains(':') && !key.starts_with('"') && !key.starts_with('\'') {
1840                // Rebuild: indent + optional list marker + quoted key + rest
1841                for _ in 0..indent {
1842                    output.push(' ');
1843                }
1844                output.push_str(list_prefix);
1845                output.push('"');
1846                output.push_str(key);
1847                output.push('"');
1848                output.push(':');
1849                output.push_str(rest);
1850                output.push_str(newline);
1851                changed = true;
1852                continue;
1853            }
1854        }
1855
1856        output.push_str(line);
1857        output.push_str(newline);
1858    }
1859
1860    (output, changed)
1861}
1862
1863/// Split `key: rest` / `key:` where key may contain colons if it was meant as a
1864/// script name. Uses the **last** colon that is followed by end/whitespace as the
1865/// mapping separator only when the key portion contains additional colons...
1866///
1867/// Standard YAML uses the first colon. For healing we treat a line as a
1868/// multi-colon key when the first segment after indent looks like
1869/// `word:word...: optional_value` with no space before the second colon.
1870fn split_yaml_mapping_key(mapping_body: &str) -> Option<(&str, &str)> {
1871    let without_comment = mapping_body
1872        .split_once(" #")
1873        .map(|(left, _)| left)
1874        .unwrap_or(mapping_body);
1875
1876    // Find the mapping separator: first `: ` or trailing `:` at end.
1877    // If key has embedded colons (`deploy:prod: value`), first `:` is not the
1878    // separator — walk to the last `:` that is either end or followed by space.
1879    let bytes = without_comment.as_bytes();
1880    let mut candidates = Vec::new();
1881    for (idx, ch) in without_comment.char_indices() {
1882        if ch != ':' {
1883            continue;
1884        }
1885        let after = idx + 1;
1886        if after >= without_comment.len() {
1887            candidates.push(idx);
1888            continue;
1889        }
1890        let next = bytes[after] as char;
1891        if next.is_whitespace() {
1892            candidates.push(idx);
1893        }
1894    }
1895    let sep = candidates.last().copied().or_else(|| {
1896        // `key:` with no space/value already covered; bare `key:value` without
1897        // space is invalid YAML for multi-token keys — also heal `a:b:c:d` as
1898        // key=`a:b:c` value=`d` when value has no spaces? Prefer last colon.
1899        without_comment.rfind(':')
1900    })?;
1901
1902    let key = without_comment[..sep].trim_end();
1903    if key.is_empty() || key.contains(' ') || key.contains('\t') {
1904        return None;
1905    }
1906    let rest = &without_comment[sep + 1..];
1907    // Reject URL-looking single keys handled elsewhere; multi-colon keys are ok.
1908    if !key.contains(':') && rest.starts_with("//") {
1909        return None;
1910    }
1911    Some((key, rest))
1912}
1913
1914/// When `services:` contains a corrupt list item (truncated commands, bad indent),
1915/// keep every list item that parses as a mapping and drop the rest.
1916fn salvage_yaml_services_sequence(content: &str) -> (String, bool) {
1917    let lines: Vec<&str> = content.split_inclusive('\n').collect();
1918    let mut services_line_idx = None;
1919    for (idx, raw) in lines.iter().enumerate() {
1920        let line = raw.strip_suffix('\n').unwrap_or(raw);
1921        if line == "services:" || line.starts_with("services:") && !line[9..].contains('|') {
1922            // only top-level `services:`
1923            if !line.starts_with(' ') && !line.starts_with('\t') {
1924                services_line_idx = Some(idx);
1925                break;
1926            }
1927        }
1928    }
1929    let Some(start) = services_line_idx else {
1930        return (content.to_string(), false);
1931    };
1932
1933    // Collect service items until next top-level key.
1934    let mut item_starts: Vec<usize> = Vec::new();
1935    let mut end = lines.len();
1936    for idx in (start + 1)..lines.len() {
1937        let line = lines[idx].strip_suffix('\n').unwrap_or(lines[idx]);
1938        let trimmed = line.trim_start();
1939        let indent = line.len().saturating_sub(trimmed.len());
1940        if indent == 0 && !trimmed.is_empty() && !trimmed.starts_with('#') {
1941            if top_level_yaml_key(trimmed).is_some() {
1942                end = idx;
1943                break;
1944            }
1945            if trimmed.starts_with('-') {
1946                item_starts.push(idx);
1947            }
1948        } else if indent == 0 && trimmed.starts_with('-') {
1949            item_starts.push(idx);
1950        }
1951    }
1952
1953    if item_starts.is_empty() {
1954        return (content.to_string(), false);
1955    }
1956
1957    let mut kept_items: Vec<String> = Vec::new();
1958    let mut dropped = 0usize;
1959    for (i, item_start) in item_starts.iter().enumerate() {
1960        let item_end = item_starts.get(i + 1).copied().unwrap_or(end);
1961        let mut item_text = String::new();
1962        for raw in &lines[*item_start..item_end] {
1963            item_text.push_str(raw);
1964        }
1965        // Parse as a one-element sequence under services.
1966        let probe = format!("services:\n{item_text}");
1967        match serde_yaml::from_str::<serde_yaml::Value>(&probe) {
1968            Ok(serde_yaml::Value::Mapping(map)) => {
1969                let ok = map
1970                    .get(serde_yaml::Value::String("services".into()))
1971                    .and_then(|v| v.as_sequence())
1972                    .map(|seq| {
1973                        seq.len() == 1
1974                            && seq[0]
1975                                .as_mapping()
1976                                .map(|m| {
1977                                    m.keys().any(|k| {
1978                                        k.as_str() == Some("name") || k.as_str() == Some("target")
1979                                    })
1980                                })
1981                                .unwrap_or(false)
1982                    })
1983                    .unwrap_or(false);
1984                if ok {
1985                    kept_items.push(item_text);
1986                } else {
1987                    dropped += 1;
1988                }
1989            }
1990            _ => dropped += 1,
1991        }
1992    }
1993
1994    if dropped == 0 {
1995        return (content.to_string(), false);
1996    }
1997
1998    let mut output = String::with_capacity(content.len());
1999    for raw in &lines[..=start] {
2000        output.push_str(raw);
2001    }
2002    for item in &kept_items {
2003        output.push_str(item);
2004    }
2005    for raw in &lines[end..] {
2006        output.push_str(raw);
2007    }
2008
2009    (output, true)
2010}
2011
2012fn merge_yaml_values(documents: Vec<serde_yaml::Value>) -> serde_yaml::Value {
2013    let mut merged = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
2014    for document in documents {
2015        deep_merge_yaml(&mut merged, document);
2016    }
2017    merged
2018}
2019
2020/// Deep-merge `incoming` into `base`. Mappings merge recursively; sequences and
2021/// scalars from later documents replace earlier values.
2022fn deep_merge_yaml(base: &mut serde_yaml::Value, incoming: serde_yaml::Value) {
2023    match (base, incoming) {
2024        (serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(incoming_map)) => {
2025            for (key, value) in incoming_map {
2026                match base_map.get_mut(&key) {
2027                    Some(existing) => deep_merge_yaml(existing, value),
2028                    None => {
2029                        base_map.insert(key, value);
2030                    }
2031                }
2032            }
2033        }
2034        (base_slot, incoming) => {
2035            *base_slot = incoming;
2036        }
2037    }
2038}
2039
2040/// Parse raw config text as a specific kind into a JSON [`Value`].
2041///
2042/// Returns `(value, structural_rewrite_needed)`.
2043fn parse_config_value_as_kind(content: &str, kind: &str) -> Result<(Value, bool), String> {
2044    match kind {
2045        "yaml" | "yml" => {
2046            let (yaml_value, meta) = parse_yaml_config_documents(content)?;
2047            let json = serde_json::to_value(yaml_value).map_err(|e| e.to_string())?;
2048            Ok((json, meta.needs_rewrite()))
2049        }
2050        "json" => {
2051            let had_bom = content.starts_with('\u{feff}');
2052            let stripped = strip_utf8_bom(content);
2053            let json = serde_json::from_str::<Value>(stripped).map_err(|e| e.to_string())?;
2054            Ok((json, had_bom))
2055        }
2056        "jsonc" => {
2057            let stripped = strip_jsonc_comments_light(strip_utf8_bom(content));
2058            let json = serde_json::from_str::<Value>(&stripped).map_err(|e| e.to_string())?;
2059            Ok((json, stripped != content))
2060        }
2061        "toml" => {
2062            let value = toml::from_str::<toml::Value>(strip_utf8_bom(content))
2063                .map_err(|e| e.to_string())?;
2064            let json = serde_json::to_value(value).map_err(|e| e.to_string())?;
2065            Ok((json, false))
2066        }
2067        _ => Err(format!("Unsupported config kind: {kind}")),
2068    }
2069}
2070
2071/// Sniff the actual serialization format of XBP config content.
2072///
2073/// Used when the file extension does not match the body (e.g. YAML saved as
2074/// `.xbp/xbp.toml`). Preference order: declared-friendly parsers that succeed
2075/// without ambiguity — TOML, JSON/JSONC, then YAML.
2076pub fn detect_config_content_kind(content: &str) -> Option<&'static str> {
2077    let stripped = strip_utf8_bom(content);
2078    let trimmed = stripped.trim_start();
2079    if trimmed.is_empty() {
2080        return None;
2081    }
2082
2083    // Fast path: JSON object/array.
2084    if (trimmed.starts_with('{') || trimmed.starts_with('['))
2085        && serde_json::from_str::<Value>(trimmed).is_ok()
2086    {
2087        return Some("json");
2088    }
2089
2090    // TOML: requires at least one successful table parse with a known top-level key.
2091    if toml::from_str::<toml::Value>(stripped).is_ok() {
2092        return Some("toml");
2093    }
2094
2095    // JSONC (JSON with // or /* */ comments).
2096    let jsonc_stripped = strip_jsonc_comments_light(stripped);
2097    if jsonc_stripped != stripped && serde_json::from_str::<Value>(&jsonc_stripped).is_ok() {
2098        return Some("jsonc");
2099    }
2100
2101    // YAML last — it is permissive and can accept many non-YAML documents.
2102    if parse_yaml_config_documents(content).is_ok() {
2103        return Some("yaml");
2104    }
2105
2106    None
2107}
2108
2109fn normalize_config_kind_name(kind: &str) -> &str {
2110    match kind {
2111        "yml" => "yaml",
2112        other => other,
2113    }
2114}
2115
2116/// Parse config content, auto-healing structural issues and format mismatches.
2117///
2118/// When `kind` (from the file extension) fails to parse but another supported
2119/// format succeeds, the content is re-serialized back into `kind` so the
2120/// extension and body match again (e.g. YAML body in `xbp.toml` → real TOML).
2121pub fn parse_config_with_auto_heal<T: DeserializeOwned>(
2122    content: &str,
2123    kind: &str,
2124) -> Result<(T, Option<String>), String> {
2125    let declared = normalize_config_kind_name(kind);
2126    let parse_declared = parse_config_value_as_kind(content, declared);
2127
2128    let (mut value, structural_heal, format_mismatch_from) = match parse_declared {
2129        Ok((value, structural)) => (value, structural, None),
2130        Err(declared_err) => {
2131            // Extension/body mismatch: e.g. YAML content under `.toml`.
2132            let fallback_order = ["toml", "yaml", "json", "jsonc"];
2133            let mut recovered = None;
2134            for candidate in fallback_order {
2135                if candidate == declared {
2136                    continue;
2137                }
2138                if let Ok((value, structural)) = parse_config_value_as_kind(content, candidate) {
2139                    recovered = Some((value, structural, candidate));
2140                    break;
2141                }
2142            }
2143            match recovered {
2144                Some((value, structural, actual_kind)) => {
2145                    // Always rewrite so the on-disk format matches the extension.
2146                    let _ = structural;
2147                    (value, true, Some(actual_kind))
2148                }
2149                None => {
2150                    let sniffed = detect_config_content_kind(content)
2151                        .map(|k| format!(" (content looks like {k})"))
2152                        .unwrap_or_default();
2153                    return Err(format!(
2154                        "Failed to parse {declared} config{sniffed}: {declared_err}. \
2155                         Run `xbp config heal` to recover a mislabeled config body."
2156                    ));
2157                }
2158            }
2159        }
2160    };
2161
2162    let field_healed = auto_heal_xbp_config_value(&mut value);
2163    let healed = field_healed || structural_heal || format_mismatch_from.is_some();
2164    let parsed = serde_json::from_value::<T>(value.clone()).map_err(|e| e.to_string())?;
2165
2166    let healed_content = if healed {
2167        Some(match declared {
2168            "yaml" => serialize_xbp_yaml(&value)?,
2169            "json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
2170            "jsonc" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
2171            "toml" => render_xbp_config_value(&value, "toml")?,
2172            _ => return Err(format!("Unsupported config kind: {declared}")),
2173        })
2174    } else {
2175        None
2176    };
2177
2178    Ok((parsed, healed_content))
2179}
2180
2181pub fn config_kind_from_path(path: &Path) -> Result<&'static str, String> {
2182    match path
2183        .extension()
2184        .and_then(|ext| ext.to_str())
2185        .unwrap_or_default()
2186        .to_ascii_lowercase()
2187        .as_str()
2188    {
2189        "yaml" | "yml" => Ok("yaml"),
2190        "json" => Ok("json"),
2191        "jsonc" => Ok("jsonc"),
2192        "toml" => Ok("toml"),
2193        other => Err(format!("Unsupported XBP config extension `{other}`.")),
2194    }
2195}
2196
2197pub fn render_xbp_config_value(value: &Value, kind: &str) -> Result<String, String> {
2198    render_xbp_config_value_for_path(value, kind, None)
2199}
2200
2201/// Like [`render_xbp_config_value`], but attaches a path-aware YAML `$schema` directive.
2202pub fn render_xbp_config_value_for_path(
2203    value: &Value,
2204    kind: &str,
2205    config_path: Option<&Path>,
2206) -> Result<String, String> {
2207    let mut value = value.clone();
2208    let _ = ensure_named_entry_key_order(&mut value);
2209    match kind {
2210        "yaml" | "yml" => {
2211            let yaml = serde_yaml::to_string(&value)
2212                .map_err(|e| format!("Failed to serialize YAML config: {e}"))?;
2213            let body = normalize_xbp_yaml_environment_quotes(&yaml);
2214            let directive = config_path
2215                .map(xbp_project_schema_directive_for_path)
2216                .unwrap_or_else(|| XBP_PROJECT_YAML_SCHEMA_DIRECTIVE.to_string());
2217            Ok(ensure_xbp_yaml_schema_directive_with(&body, &directive))
2218        }
2219        "json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string()),
2220        "jsonc" => {
2221            // JSONC is JSON-compatible; keep pretty JSON (comments are not re-emitted).
2222            serde_json::to_string_pretty(&value).map_err(|e| e.to_string())
2223        }
2224        "toml" => {
2225            let toml_value = json_value_to_toml(&value)?;
2226            toml::to_string_pretty(&toml_value).map_err(|e| e.to_string())
2227        }
2228        _ => Err(format!("Unsupported XBP config kind `{kind}`.")),
2229    }
2230}
2231
2232fn json_value_to_toml(value: &Value) -> Result<toml::Value, String> {
2233    match value {
2234        Value::Object(entries) => {
2235            let mut table = toml::map::Map::new();
2236            for (key, value) in entries {
2237                if value.is_null() {
2238                    continue;
2239                }
2240                table.insert(key.clone(), json_value_to_toml(value)?);
2241            }
2242            Ok(toml::Value::Table(table))
2243        }
2244        Value::Array(values) => Ok(toml::Value::Array(
2245            values
2246                .iter()
2247                .map(json_value_to_toml)
2248                .collect::<Result<_, _>>()?,
2249        )),
2250        Value::String(value) => Ok(toml::Value::String(value.clone())),
2251        Value::Bool(value) => Ok(toml::Value::Boolean(*value)),
2252        Value::Number(value) => value
2253            .as_i64()
2254            .map(toml::Value::Integer)
2255            .or_else(|| {
2256                value
2257                    .as_u64()
2258                    .and_then(|number| i64::try_from(number).ok())
2259                    .map(toml::Value::Integer)
2260            })
2261            .or_else(|| value.as_f64().map(toml::Value::Float))
2262            .ok_or_else(|| "Unsupported JSON number in TOML conversion.".to_string()),
2263        Value::Null => Err("Null cannot be represented in TOML.".to_string()),
2264    }
2265}
2266
2267/// Remove JSONC comments and trailing commas without interpreting text inside
2268/// JSON strings. This is shared by project-config parsing and Wrangler-aware
2269/// commands so JSONC and JSON have identical data semantics.
2270pub fn strip_jsonc_comments_light(input: &str) -> String {
2271    let mut output = String::with_capacity(input.len());
2272    let bytes = input.as_bytes();
2273    let mut index = 0usize;
2274    let mut in_string = false;
2275    let mut escaped = false;
2276    while index < bytes.len() {
2277        let character = bytes[index] as char;
2278        if in_string {
2279            output.push(character);
2280            if escaped {
2281                escaped = false;
2282            } else if character == '\\' {
2283                escaped = true;
2284            } else if character == '"' {
2285                in_string = false;
2286            }
2287            index += 1;
2288            continue;
2289        }
2290        if character == '"' {
2291            in_string = true;
2292            output.push(character);
2293            index += 1;
2294            continue;
2295        }
2296        if character == '/' && index + 1 < bytes.len() {
2297            match bytes[index + 1] as char {
2298                '/' => {
2299                    index += 2;
2300                    while index < bytes.len() && bytes[index] != b'\n' {
2301                        index += 1;
2302                    }
2303                    continue;
2304                }
2305                '*' => {
2306                    index += 2;
2307                    while index + 1 < bytes.len()
2308                        && !(bytes[index] == b'*' && bytes[index + 1] == b'/')
2309                    {
2310                        index += 1;
2311                    }
2312                    index = (index + 2).min(bytes.len());
2313                    continue;
2314                }
2315                _ => {}
2316            }
2317        }
2318        if character == ',' {
2319            let mut lookahead = index + 1;
2320            while lookahead < bytes.len() && (bytes[lookahead] as char).is_whitespace() {
2321                lookahead += 1;
2322            }
2323            if lookahead < bytes.len() && matches!(bytes[lookahead] as char, '}' | ']') {
2324                index += 1;
2325                continue;
2326            }
2327        }
2328        output.push(character);
2329        index += 1;
2330    }
2331    output
2332}
2333
2334#[derive(Debug, Clone, PartialEq, Eq)]
2335pub struct XbpConfigHealResult {
2336    pub config_path: PathBuf,
2337    pub fixes: Vec<String>,
2338}
2339
2340pub fn detect_xbp_config_heal_opportunities(content: &str) -> Vec<String> {
2341    detect_xbp_config_heal_opportunities_for_kind(content, None)
2342}
2343
2344/// Detect heal opportunities for config content, optionally given the path/extension kind.
2345pub fn detect_xbp_config_heal_opportunities_for_kind(
2346    content: &str,
2347    declared_kind: Option<&str>,
2348) -> Vec<String> {
2349    let mut fixes = Vec::new();
2350    let declared = declared_kind.map(normalize_config_kind_name);
2351
2352    if content.starts_with('\u{feff}') {
2353        fixes.push("strip leading UTF-8 BOM".to_string());
2354    }
2355
2356    if let Some(declared) = declared {
2357        if parse_config_value_as_kind(content, declared).is_err() {
2358            if let Some(actual) = detect_config_content_kind(content) {
2359                if actual != declared {
2360                    fixes.push(format!(
2361                        "rewrite {actual} body as {declared} (extension/content mismatch)"
2362                    ));
2363                }
2364            }
2365        }
2366    }
2367
2368    // YAML-specific structural diagnostics only — TOML/JSON command tables often
2369    // contain `key: value` *inside strings* which would false-positive as YAML keys.
2370    let run_yaml_structure_checks = match declared {
2371        Some("yaml") => true,
2372        Some("toml" | "json" | "jsonc") => false,
2373        // Unknown/legacy callers: only scan YAML structure when the body sniffs as YAML.
2374        None | Some(_) => matches!(
2375            detect_config_content_kind(content),
2376            Some("yaml") | None
2377        ),
2378    };
2379
2380    if run_yaml_structure_checks {
2381        let stripped = strip_utf8_bom(content);
2382        // Count non-empty YAML documents; multi-doc streams are collapsed on load.
2383        if content_looks_like_yaml_multidoc(stripped) {
2384            fixes.push("collapse multi-document YAML into a single document".to_string());
2385        }
2386
2387        let mut top_level_keys: HashMap<String, usize> = HashMap::new();
2388        for line in stripped.lines() {
2389            let trimmed = line.trim_start();
2390            if line.len() != trimmed.len() {
2391                continue;
2392            }
2393            if let Some(key) = top_level_yaml_key(trimmed) {
2394                *top_level_keys.entry(key).or_insert(0) += 1;
2395            }
2396        }
2397        let mut duplicate_keys: Vec<_> = top_level_keys
2398            .into_iter()
2399            .filter(|(_, count)| *count > 1)
2400            .map(|(key, count)| format!("resolve {count} duplicate top-level `{key}` blocks"))
2401            .collect();
2402        duplicate_keys.sort();
2403        fixes.extend(duplicate_keys);
2404
2405        // Nested duplicate mapping keys (e.g. two `environment:` under one service).
2406        // Detect via a dry structural pass when serde would reject the document.
2407        let stripped_for_nested = strip_utf8_bom(content);
2408        if serde_yaml::from_str::<serde_yaml::Value>(stripped_for_nested)
2409            .ok()
2410            .is_none()
2411        {
2412            let (nested_healed, nested_changed) =
2413                dedupe_yaml_nested_mapping_keys(stripped_for_nested);
2414            if nested_changed
2415                && serde_yaml::from_str::<serde_yaml::Value>(&nested_healed).is_ok()
2416            {
2417                fixes.push(
2418                    "resolve duplicate nested mapping keys (e.g. services[].environment)"
2419                        .to_string(),
2420                );
2421            } else if let Err(err) =
2422                serde_yaml::from_str::<serde_yaml::Value>(stripped_for_nested)
2423            {
2424                let msg = err.to_string();
2425                if msg.contains("duplicate entry with key") {
2426                    fixes.push(format!("resolve nested YAML duplicate keys ({msg})"));
2427                }
2428            }
2429        }
2430
2431        for line in content.lines() {
2432            let trimmed = line.trim();
2433            if !trimmed.starts_with("systemd:") {
2434                continue;
2435            }
2436            let value = trimmed.trim_start_matches("systemd:").trim();
2437            if value.is_empty() || value == "null" || value.starts_with('{') {
2438                continue;
2439            }
2440            let normalized = value.trim_matches('"').trim_matches('\'');
2441            fixes.push(format!(
2442                "normalize `systemd: {normalized}` to `systemd_service_name`"
2443            ));
2444        }
2445    }
2446
2447    fixes.sort();
2448    fixes.dedup();
2449    fixes
2450}
2451
2452fn content_looks_like_yaml_multidoc(content: &str) -> bool {
2453    let mut document_count = 0usize;
2454    for document in serde_yaml::Deserializer::from_str(content) {
2455        match serde_yaml::Value::deserialize(document) {
2456            Ok(serde_yaml::Value::Null) => {}
2457            Ok(_) => {
2458                document_count += 1;
2459                if document_count > 1 {
2460                    return true;
2461                }
2462            }
2463            Err(_) => return false,
2464        }
2465    }
2466    false
2467}
2468
2469pub fn heal_project_xbp_config(start_dir: &Path) -> Result<Option<XbpConfigHealResult>, String> {
2470    let found = match find_xbp_config_upwards(start_dir) {
2471        Some(found) => found,
2472        None => return Ok(None),
2473    };
2474
2475    heal_config_path(&found.config_path, found.kind)
2476}
2477
2478/// Heal a single XBP config file at `path` (declared `kind` from extension).
2479///
2480/// Returns `Ok(None)` when the file is already healthy.
2481pub fn heal_config_path(path: &Path, kind: &str) -> Result<Option<XbpConfigHealResult>, String> {
2482    let content = fs::read_to_string(path)
2483        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2484    let mut fixes = detect_xbp_config_heal_opportunities_for_kind(&content, Some(kind));
2485    let healed = heal_config_file(path, kind)?;
2486
2487    if !healed && fixes.is_empty() {
2488        return Ok(None);
2489    }
2490
2491    if fixes.is_empty() {
2492        fixes.push("normalized legacy config fields".to_string());
2493    }
2494
2495    Ok(Some(XbpConfigHealResult {
2496        config_path: path.to_path_buf(),
2497        fixes,
2498    }))
2499}
2500
2501/// Heal every repository-bound XBP config under `start_dir` (root + nested `.xbp/xbp.*`).
2502pub fn heal_project_xbp_configs(
2503    start_dir: &Path,
2504    include_nested: bool,
2505) -> Result<Vec<XbpConfigHealResult>, String> {
2506    let mut results = Vec::new();
2507
2508    if let Some(result) = heal_project_xbp_config(start_dir)? {
2509        results.push(result);
2510    }
2511
2512    if !include_nested {
2513        return Ok(results);
2514    }
2515
2516    let root = find_xbp_config_upwards(start_dir)
2517        .map(|f| f.project_root)
2518        .unwrap_or_else(|| start_dir.to_path_buf());
2519
2520    let mut nested_paths: Vec<PathBuf> = Vec::new();
2521    collect_nested_xbp_config_paths(&root, &root, &mut nested_paths)?;
2522    nested_paths.sort();
2523    nested_paths.dedup();
2524
2525    for path in nested_paths {
2526        let kind = config_kind_from_path(&path)?;
2527        // Skip the root config already handled above.
2528        if find_xbp_config_upwards(start_dir)
2529            .map(|f| f.config_path == path)
2530            .unwrap_or(false)
2531        {
2532            continue;
2533        }
2534        if let Some(result) = heal_config_path(&path, kind)? {
2535            results.push(result);
2536        }
2537    }
2538
2539    Ok(results)
2540}
2541
2542fn collect_nested_xbp_config_paths(
2543    project_root: &Path,
2544    dir: &Path,
2545    out: &mut Vec<PathBuf>,
2546) -> Result<(), String> {
2547    let entries = match fs::read_dir(dir) {
2548        Ok(entries) => entries,
2549        Err(_) => return Ok(()),
2550    };
2551
2552    for entry in entries.flatten() {
2553        let path = entry.path();
2554        let name = entry.file_name();
2555        let name = name.to_string_lossy();
2556        if path.is_dir() {
2557            if DEFAULT_DISCOVERY_SKIP_DIRS
2558                .iter()
2559                .any(|skip| name.eq_ignore_ascii_case(skip))
2560                || name.starts_with('.') && name != ".xbp"
2561            {
2562                // Still descend into `.xbp` for config files; skip other dot-dirs.
2563                if name == ".xbp" {
2564                    collect_nested_xbp_config_paths(project_root, &path, out)?;
2565                }
2566                continue;
2567            }
2568            collect_nested_xbp_config_paths(project_root, &path, out)?;
2569            continue;
2570        }
2571
2572        let file_name = name.as_ref();
2573        if matches!(
2574            file_name,
2575            "xbp.toml" | "xbp.yaml" | "xbp.yml" | "xbp.json" | "xbp.jsonc"
2576        ) {
2577            // Only accept configs under a `.xbp` directory.
2578            if path
2579                .parent()
2580                .and_then(|p| p.file_name())
2581                .and_then(|n| n.to_str())
2582                == Some(".xbp")
2583            {
2584                out.push(path);
2585            }
2586        }
2587    }
2588
2589    Ok(())
2590}
2591
2592pub fn heal_config_file(path: &Path, kind: &str) -> Result<bool, String> {
2593    let content = fs::read_to_string(path)
2594        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
2595    let (_, healed_content) = parse_config_with_auto_heal::<Value>(&content, kind)?;
2596
2597    if let Some(healed_content) = healed_content {
2598        if healed_content != content {
2599            fs::write(path, healed_content).map_err(|e| {
2600                format!("Failed to write healed config {}: {}", path.display(), e)
2601            })?;
2602            return Ok(true);
2603        }
2604    }
2605
2606    Ok(false)
2607}
2608
2609pub(crate) fn normalize_xbp_config_value(value: &mut Value) -> bool {
2610    auto_heal_xbp_config_value(value)
2611}
2612
2613fn auto_heal_xbp_config_value(value: &mut Value) -> bool {
2614    let mut changed = ensure_named_entry_key_order(value);
2615
2616    let Some(root) = value.as_object_mut() else {
2617        return changed;
2618    };
2619
2620    if let Some(environment) = root.get_mut("environment") {
2621        changed |= normalize_environment_value(environment);
2622    }
2623
2624    changed |= normalize_systemd_shorthand(root);
2625
2626    if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
2627        for service in services {
2628            let Some(service) = service.as_object_mut() else {
2629                continue;
2630            };
2631
2632            if let Some(environment) = service.get_mut("environment") {
2633                changed |= normalize_environment_value(environment);
2634            }
2635
2636            changed |= normalize_systemd_shorthand(service);
2637            changed |= normalize_boolish_fields(service, SERVICE_BOOL_KEYS);
2638        }
2639    }
2640
2641    // Re-apply name-first order after other heals that rebuild maps.
2642    changed |= ensure_named_entry_key_order(value);
2643
2644    changed
2645}
2646
2647/// Service-level keys that deserialize as `bool` / `Option<bool>`.
2648/// Hand-edited configs (especially TOML) often quote these as `"true"` / `"false"`.
2649const SERVICE_BOOL_KEYS: &[&str] = &[
2650    "target_freeze",
2651    "freeze_target",
2652    "target_frozen",
2653    "force_run_from_root",
2654    "versioning",
2655    "versioning_enabled",
2656    "enable_versioning",
2657    "release",
2658    "release_enabled",
2659    "enable_release",
2660];
2661
2662/// Coerce string/number placeholders into JSON booleans for known keys.
2663/// Returns true when any value changed.
2664fn normalize_boolish_fields(map: &mut Map<String, Value>, keys: &[&str]) -> bool {
2665    let mut changed = false;
2666    for key in keys {
2667        let Some(value) = map.get_mut(*key) else {
2668            continue;
2669        };
2670        if let Some(coerced) = coerce_boolish_value(value) {
2671            *value = coerced;
2672            changed = true;
2673        }
2674    }
2675    changed
2676}
2677
2678/// Convert `"true"` / `"false"` / `1` / `0` (and common variants) into a JSON bool.
2679/// Returns `Some(bool Value)` when coercion is needed, `None` when already a bool or not bool-like.
2680fn coerce_boolish_value(value: &Value) -> Option<Value> {
2681    match value {
2682        Value::Bool(_) => None,
2683        Value::String(raw) => {
2684            let trimmed = raw.trim();
2685            if trimmed.eq_ignore_ascii_case("true")
2686                || trimmed.eq_ignore_ascii_case("yes")
2687                || trimmed.eq_ignore_ascii_case("on")
2688                || trimmed == "1"
2689            {
2690                Some(Value::Bool(true))
2691            } else if trimmed.eq_ignore_ascii_case("false")
2692                || trimmed.eq_ignore_ascii_case("no")
2693                || trimmed.eq_ignore_ascii_case("off")
2694                || trimmed == "0"
2695            {
2696                Some(Value::Bool(false))
2697            } else {
2698                None
2699            }
2700        }
2701        Value::Number(number) => {
2702            if number.as_u64() == Some(1) || number.as_i64() == Some(1) {
2703                Some(Value::Bool(true))
2704            } else if number.as_u64() == Some(0) || number.as_i64() == Some(0) {
2705                Some(Value::Bool(false))
2706            } else {
2707                None
2708            }
2709        }
2710        _ => None,
2711    }
2712}
2713
2714/// Preferred key order for `services[]` entries (matches `ServiceConfig` field order).
2715/// `name` is always first so TOML/YAML/JSON rewrites stay readable and stable.
2716const SERVICE_KEY_ORDER: &[&str] = &[
2717    "name",
2718    "target",
2719    "target_freeze",
2720    "branch",
2721    "port",
2722    "root_directory",
2723    "environment",
2724    "url",
2725    "healthcheck_path",
2726    "restart_policy",
2727    "restart_policy_max_failure_count",
2728    "start_wrapper",
2729    "commands",
2730    "force_run_from_root",
2731    "version_targets",
2732    "depends_on",
2733    "watch_paths",
2734    "versioning",
2735    "release",
2736    "systemd_service_name",
2737    "systemd",
2738    "openapi",
2739    "oci",
2740    "deploy",
2741    "discord",
2742];
2743
2744/// Preferred key order for `workers[]` entries.
2745const WORKER_KEY_ORDER: &[&str] = &[
2746    "name",
2747    "root",
2748    "script_name",
2749    "service",
2750    "deploy",
2751    "container",
2752    "containers",
2753    "durable_objects",
2754];
2755
2756/// Ensure `name` is the first key in each `services[]` / `workers[]` object.
2757/// Returns true when any object key order changed.
2758fn ensure_named_entry_key_order(value: &mut Value) -> bool {
2759    let Some(root) = value.as_object_mut() else {
2760        return false;
2761    };
2762    let mut changed = false;
2763
2764    if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
2765        for service in services {
2766            if let Some(obj) = service.as_object_mut() {
2767                changed |= reorder_object_keys(obj, SERVICE_KEY_ORDER);
2768            }
2769        }
2770    }
2771
2772    if let Some(workers) = root.get_mut("workers").and_then(Value::as_array_mut) {
2773        for worker in workers {
2774            if let Some(obj) = worker.as_object_mut() {
2775                changed |= reorder_object_keys(obj, WORKER_KEY_ORDER);
2776            }
2777        }
2778    }
2779
2780    changed
2781}
2782
2783/// Rebuild a JSON object with preferred keys first (in listed order), then any
2784/// remaining keys in their current relative order. No-op when already ordered.
2785fn reorder_object_keys(map: &mut Map<String, Value>, preferred: &[&str]) -> bool {
2786    if map.is_empty() {
2787        return false;
2788    }
2789
2790    let current_keys: Vec<String> = map.keys().cloned().collect();
2791    let mut desired: Vec<String> = Vec::with_capacity(current_keys.len());
2792    for key in preferred {
2793        if map.contains_key(*key) {
2794            desired.push((*key).to_string());
2795        }
2796    }
2797    for key in &current_keys {
2798        if !desired.iter().any(|k| k == key) {
2799            desired.push(key.clone());
2800        }
2801    }
2802
2803    if current_keys == desired {
2804        return false;
2805    }
2806
2807    let mut reordered = Map::new();
2808    for key in &desired {
2809        if let Some(value) = map.remove(key) {
2810            reordered.insert(key.clone(), value);
2811        }
2812    }
2813    // Any leftovers (shouldn't happen).
2814    let leftovers: Vec<(String, Value)> = map.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
2815    for (key, value) in leftovers {
2816        map.remove(&key);
2817        reordered.insert(key, value);
2818    }
2819    *map = reordered;
2820    true
2821}
2822
2823fn normalize_systemd_shorthand(map: &mut Map<String, Value>) -> bool {
2824    let Some(systemd) = map.get("systemd") else {
2825        return false;
2826    };
2827
2828    let Value::String(service_name) = systemd else {
2829        return false;
2830    };
2831
2832    let systemd_service_name_missing = map
2833        .get("systemd_service_name")
2834        .map(|value| value.is_null())
2835        .unwrap_or(true);
2836
2837    if systemd_service_name_missing && !service_name.is_empty() {
2838        map.insert(
2839            "systemd_service_name".to_string(),
2840            Value::String(service_name.clone()),
2841        );
2842    }
2843
2844    map.remove("systemd");
2845    true
2846}
2847
2848fn normalize_environment_value(value: &mut Value) -> bool {
2849    let Value::Object(map) = value else {
2850        return false;
2851    };
2852
2853    let original = map.clone();
2854    let mut normalized = Map::new();
2855    let mut changed = false;
2856
2857    flatten_environment_entries(&original, &mut normalized, &mut changed);
2858
2859    if normalized != original {
2860        *map = normalized;
2861        changed = true;
2862    }
2863
2864    changed
2865}
2866
2867fn flatten_environment_entries(
2868    source: &Map<String, Value>,
2869    target: &mut Map<String, Value>,
2870    changed: &mut bool,
2871) {
2872    for (key, value) in source {
2873        match value {
2874            Value::String(string) => {
2875                let cleaned = unwrap_redundant_env_string_quotes(string);
2876                if cleaned != *string {
2877                    *changed = true;
2878                }
2879                target.insert(key.clone(), Value::String(cleaned));
2880            }
2881            Value::Number(number) => {
2882                *changed = true;
2883                target.insert(key.clone(), Value::String(number.to_string()));
2884            }
2885            Value::Bool(boolean) => {
2886                *changed = true;
2887                target.insert(key.clone(), Value::String(boolean.to_string()));
2888            }
2889            Value::Null => {
2890                *changed = true;
2891                target.insert(key.clone(), Value::String(String::new()));
2892            }
2893            Value::Array(items) => {
2894                *changed = true;
2895                let serialized = serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string());
2896                target.insert(key.clone(), Value::String(serialized));
2897            }
2898            Value::Object(nested) => {
2899                *changed = true;
2900                flatten_environment_entries(nested, target, changed);
2901            }
2902        }
2903    }
2904}
2905
2906/// Peel redundant quote layers from already-parsed environment values.
2907///
2908/// Generators / hand-edits often produce YAML like:
2909/// `APPLE_CLIENT_ID: '"com.suitsconnect"'`
2910/// which deserializes to the string `"com.suitsconnect"` (quotes included).
2911/// Strip full outer `"` / `'` wraps (up to a few layers) so runtime gets the
2912/// real value. JSON objects (`{...}`) and bare placeholders are left alone.
2913fn unwrap_redundant_env_string_quotes(value: &str) -> String {
2914    let mut current = value.trim().to_string();
2915    for _ in 0..4 {
2916        let next = unwrap_one_env_quote_layer(&current);
2917        if next == current {
2918            break;
2919        }
2920        current = next;
2921    }
2922    current
2923}
2924
2925fn unwrap_one_env_quote_layer(value: &str) -> String {
2926    let trimmed = value.trim();
2927    if trimmed.len() < 2 {
2928        return trimmed.to_string();
2929    }
2930
2931    // Full double-quoted wrap: `"com.suitsconnect"` or `"https://..."`.
2932    if trimmed.starts_with('"') && trimmed.ends_with('"') {
2933        if env_string_is_fully_double_quoted(trimmed) {
2934            let inner = &trimmed[1..trimmed.len() - 1];
2935            return expand_basic_double_quoted_escapes(inner);
2936        }
2937        return trimmed.to_string();
2938    }
2939
2940    // Full single-quoted wrap: `'com.suitsconnect'` (YAML/dotenv style).
2941    if trimmed.starts_with('\'') && trimmed.ends_with('\'') {
2942        // Only peel when the entire value is one single-quoted scalar (no bare
2943        // unescaped `'` inside except doubled `''`).
2944        if env_string_is_fully_single_quoted(trimmed) {
2945            let inner = &trimmed[1..trimmed.len() - 1];
2946            return inner.replace("''", "'");
2947        }
2948    }
2949
2950    trimmed.to_string()
2951}
2952
2953fn env_string_is_fully_double_quoted(value: &str) -> bool {
2954    if !value.starts_with('"') || value.len() < 2 {
2955        return false;
2956    }
2957    let bytes = value.as_bytes();
2958    let mut i = 1usize;
2959    while i < bytes.len() {
2960        if bytes[i] == b'\\' {
2961            i += 2;
2962            continue;
2963        }
2964        if bytes[i] == b'"' {
2965            return i + 1 == bytes.len();
2966        }
2967        i += 1;
2968    }
2969    false
2970}
2971
2972fn env_string_is_fully_single_quoted(value: &str) -> bool {
2973    if !value.starts_with('\'') || !value.ends_with('\'') || value.len() < 2 {
2974        return false;
2975    }
2976    // Single-quoted YAML: `''` is escaped quote; any other `'` would end early.
2977    let inner = &value[1..value.len() - 1];
2978    let mut chars = inner.chars().peekable();
2979    while let Some(ch) = chars.next() {
2980        if ch == '\'' {
2981            if chars.peek() == Some(&'\'') {
2982                chars.next();
2983                continue;
2984            }
2985            return false;
2986        }
2987    }
2988    true
2989}
2990
2991fn expand_basic_double_quoted_escapes(input: &str) -> String {
2992    let mut out = String::with_capacity(input.len());
2993    let mut chars = input.chars().peekable();
2994    while let Some(ch) = chars.next() {
2995        if ch != '\\' {
2996            out.push(ch);
2997            continue;
2998        }
2999        match chars.next() {
3000            Some('n') => out.push('\n'),
3001            Some('r') => out.push('\r'),
3002            Some('t') => out.push('\t'),
3003            Some('\\') => out.push('\\'),
3004            Some('"') => out.push('"'),
3005            Some('\'') => out.push('\''),
3006            Some(other) => out.push(other),
3007            None => out.push('\\'),
3008        }
3009    }
3010    out
3011}
3012
3013#[cfg(test)]
3014mod tests {
3015    use super::{
3016        command_exists, default_project_config_path, detect_xbp_config_heal_opportunities,
3017        find_xbp_config_upwards, first_available_command, git_remote_url_from_metadata,
3018        maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal,
3019        parse_github_repo_from_remote_url, preferred_pip_command, preferred_python_command,
3020        redact_remote_url_credentials, render_xbp_config_value, resolve_xbp_project_for_path,
3021        write_json_config_from_any_xbp_config, write_xbp_project_config_at_path, KnownXbpProject,
3022    };
3023    use crate::strategies::XbpConfig;
3024    use serde_json::Value;
3025    use std::fs;
3026    use std::path::PathBuf;
3027    use std::sync::{Mutex, OnceLock};
3028
3029    fn path_lock() -> &'static Mutex<()> {
3030        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3031        LOCK.get_or_init(|| Mutex::new(()))
3032    }
3033
3034    fn make_temp_path(name: &str) -> PathBuf {
3035        let mut path = std::env::temp_dir();
3036        path.push(format!("xbp-test-{}-{}", name, std::process::id()));
3037        path
3038    }
3039
3040    fn with_path<F>(entries: &[PathBuf], test: F)
3041    where
3042        F: FnOnce(),
3043    {
3044        let _guard = path_lock().lock().expect("path lock should be available");
3045        let original = std::env::var_os("PATH");
3046        let joined = std::env::join_paths(entries).expect("PATH entries should join");
3047        std::env::set_var("PATH", joined);
3048        test();
3049        match original {
3050            Some(path) => std::env::set_var("PATH", path),
3051            None => std::env::remove_var("PATH"),
3052        }
3053    }
3054
3055    #[test]
3056    fn parses_yaml_with_leading_utf8_bom_and_rewrites_clean_file() {
3057        // serde_yaml::from_str fails on BOM with a misleading multi-document error.
3058        let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n";
3059
3060        let (config, healed_content) =
3061            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("BOM yaml should parse");
3062
3063        assert_eq!(config.project_name, "demo");
3064        assert_eq!(config.port, 3000);
3065        let healed = healed_content.expect("BOM should trigger autonomous rewrite");
3066        assert!(
3067            !healed.starts_with('\u{feff}'),
3068            "healed yaml must not retain BOM"
3069        );
3070        assert!(healed.contains("project_name: demo"));
3071    }
3072
3073    #[test]
3074    fn heals_duplicate_nested_environment_under_service() {
3075        // Generators sometimes emit two `environment:` maps under one service.
3076        // serde_yaml fails with: services[0]: duplicate entry with key "environment"
3077        let yaml = r#"
3078project_name: demo
3079version: 1.0.0
3080port: 3000
3081build_dir: ./
3082services:
3083- name: app
3084  target: nextjs
3085  branch: main
3086  port: 3000
3087  environment:
3088    A: "1"
3089    SHARED: first
3090  root_directory: ./
3091  environment:
3092    B: "2"
3093    SHARED: second
3094"#;
3095
3096        let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
3097            .expect("duplicate nested environment should heal");
3098        let services = config.services.expect("services");
3099        assert_eq!(services.len(), 1);
3100        let env = services[0]
3101            .environment
3102            .as_ref()
3103            .expect("merged environment");
3104        assert_eq!(env.get("A"), Some(&"1".to_string()));
3105        assert_eq!(env.get("B"), Some(&"2".to_string()));
3106        assert_eq!(
3107            env.get("SHARED"),
3108            Some(&"second".to_string()),
3109            "later environment block should win on scalar conflicts"
3110        );
3111
3112        let healed = healed_content.expect("should rewrite healed yaml");
3113        let env_keys_in_first_service = healed
3114            .lines()
3115            .filter(|line| *line == "  environment:" || line.starts_with("  environment: "))
3116            .count();
3117        assert_eq!(
3118            env_keys_in_first_service, 1,
3119            "exactly one environment key under the service: {healed}"
3120        );
3121        assert!(healed.contains("A:") || healed.contains("A: "));
3122        assert!(healed.contains("B:") || healed.contains("B: "));
3123    }
3124
3125    #[test]
3126    fn discovers_and_round_trips_all_project_config_formats() {
3127        let root = make_temp_path("multi-format-config");
3128        let _ = fs::remove_dir_all(&root);
3129        fs::create_dir_all(root.join(".xbp")).expect("mkdir");
3130
3131        let baseline = serde_json::json!({
3132            "project_name": "multi",
3133            "port": 3000,
3134            "build_dir": "./",
3135            "services": [{
3136                "name": "api",
3137                "target": "rust",
3138                "branch": "main",
3139                "port": 8080
3140            }]
3141        });
3142
3143        for kind in ["yaml", "json", "jsonc", "toml"] {
3144            let path = default_project_config_path(&root, kind);
3145            write_xbp_project_config_at_path(&path, &baseline).expect("write");
3146            assert!(path.exists(), "{kind} path missing");
3147
3148            let found = find_xbp_config_upwards(&root).expect("discover");
3149            assert_eq!(found.kind, if kind == "yml" { "yaml" } else { kind });
3150            assert_eq!(found.config_path, path);
3151
3152            let content = fs::read_to_string(&path).expect("read");
3153            let (parsed, _) =
3154                parse_config_with_auto_heal::<Value>(&content, found.kind).expect("parse");
3155            assert_eq!(parsed["project_name"], "multi");
3156            assert_eq!(parsed["services"][0]["name"], "api");
3157
3158            // Rewrite in place and ensure format is preserved.
3159            write_xbp_project_config_at_path(&path, &parsed).expect("rewrite");
3160            let found_again = find_xbp_config_upwards(&root).expect("rediscover");
3161            assert_eq!(found_again.config_path, path);
3162
3163            let _ = fs::remove_file(&path);
3164        }
3165
3166        let _ = fs::remove_dir_all(&root);
3167    }
3168
3169    #[test]
3170    fn service_name_key_is_first_in_yaml_and_toml() {
3171        // BTreeMap-style alphabetical order puts `branch` before `name`; we force
3172        // `name` first (especially important for TOML table readability).
3173        let value = serde_json::json!({
3174            "project_name": "demo",
3175            "port": 3000,
3176            "build_dir": "./",
3177            "services": [{
3178                "branch": "main",
3179                "port": 3000,
3180                "target": "nextjs",
3181                "name": "app",
3182                "root_directory": "./"
3183            }],
3184            "workers": [{
3185                "root": "apps/api",
3186                "name": "api"
3187            }]
3188        });
3189
3190        for kind in ["yaml", "toml", "json"] {
3191            let rendered = render_xbp_config_value(&value, kind).expect("render");
3192            match kind {
3193                "yaml" => {
3194                    // First key under the service list item should be name.
3195                    let mut saw_service_item = false;
3196                    let mut first_key: Option<String> = None;
3197                    for line in rendered.lines() {
3198                        let trimmed = line.trim_start();
3199                        if trimmed.starts_with("- ") || trimmed.starts_with("- name:") {
3200                            saw_service_item = true;
3201                            if let Some(rest) = trimmed.strip_prefix("- ") {
3202                                if let Some((key, _)) = rest.split_once(':') {
3203                                    first_key = Some(key.trim().to_string());
3204                                    break;
3205                                }
3206                            }
3207                            continue;
3208                        }
3209                        if saw_service_item && !trimmed.is_empty() && !trimmed.starts_with('#') {
3210                            if let Some((key, _)) = trimmed.split_once(':') {
3211                                first_key = Some(key.trim().to_string());
3212                                break;
3213                            }
3214                        }
3215                    }
3216                    assert_eq!(
3217                        first_key.as_deref(),
3218                        Some("name"),
3219                        "yaml service first key should be name:\n{rendered}"
3220                    );
3221                }
3222                "toml" => {
3223                    // [[services]] then name = "app" before branch/port/target
3224                    let services_idx = rendered.find("[[services]]").expect("services table");
3225                    let after = &rendered[services_idx..];
3226                    let name_idx = after.find("name = ").expect("name key");
3227                    let branch_idx = after.find("branch = ").expect("branch key");
3228                    assert!(
3229                        name_idx < branch_idx,
3230                        "toml services name must precede branch:\n{rendered}"
3231                    );
3232                }
3233                "json" => {
3234                    let parsed: Value =
3235                        serde_json::from_str(&rendered).expect("json parse");
3236                    let keys: Vec<&str> = parsed["services"][0]
3237                        .as_object()
3238                        .expect("service object")
3239                        .keys()
3240                        .map(|k| k.as_str())
3241                        .collect();
3242                    assert_eq!(keys.first().copied(), Some("name"), "json keys: {keys:?}");
3243                }
3244                _ => {}
3245            }
3246        }
3247    }
3248
3249    #[test]
3250    fn heals_double_quoted_environment_string_values() {
3251        // YAML `KEY: '"value"'` deserializes to string content `"value"` (quotes included).
3252        let yaml = r#"
3253project_name: demo
3254version: 1.0.0
3255port: 3000
3256build_dir: ./
3257environment:
3258  APPLE_CLIENT_ID: '"com.suitsconnect"'
3259  ATHENA_URL: '"https://mirror1.athena-cluster.com"'
3260  PLACEHOLDER: ${DATABASE_URL}
3261  JSON_OK: '{"a":1}'
3262services:
3263- name: app
3264  target: nextjs
3265  branch: main
3266  port: 3000
3267  environment:
3268    BETTER_AUTH_SECRET: '"380d1e84385f4b56ce2f39b56da618c9d8e94de6af2a034bdc1f0105ed63590e"'
3269    BARE: plain
3270"#;
3271
3272        let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
3273            .expect("double-quoted env values should heal");
3274        let root_env = config.environment.expect("root environment");
3275        assert_eq!(
3276            root_env.get("APPLE_CLIENT_ID"),
3277            Some(&"com.suitsconnect".to_string())
3278        );
3279        assert_eq!(
3280            root_env.get("ATHENA_URL"),
3281            Some(&"https://mirror1.athena-cluster.com".to_string())
3282        );
3283        assert_eq!(
3284            root_env.get("PLACEHOLDER"),
3285            Some(&"${DATABASE_URL}".to_string())
3286        );
3287        // JSON-looking single-quoted payload peels to the JSON object string.
3288        assert_eq!(root_env.get("JSON_OK"), Some(&r#"{"a":1}"#.to_string()));
3289
3290        let svc_env = config
3291            .services
3292            .as_ref()
3293            .and_then(|s| s.first())
3294            .and_then(|s| s.environment.as_ref())
3295            .expect("service environment");
3296        assert_eq!(
3297            svc_env.get("BETTER_AUTH_SECRET"),
3298            Some(&"380d1e84385f4b56ce2f39b56da618c9d8e94de6af2a034bdc1f0105ed63590e".to_string())
3299        );
3300        assert_eq!(svc_env.get("BARE"), Some(&"plain".to_string()));
3301
3302        let healed = healed_content.expect("quote unwrap should rewrite config");
3303        assert!(
3304            !healed.contains("'\"com.suitsconnect\"'"),
3305            "healed yaml must not keep nested quote wrap: {healed}"
3306        );
3307        assert!(
3308            healed.contains("com.suitsconnect"),
3309            "healed yaml should keep unwrapped value: {healed}"
3310        );
3311    }
3312
3313    #[test]
3314    fn drops_duplicate_top_level_openapi_null_and_keeps_first_block() {
3315        let yaml = r#"
3316project_name: athena
3317version: 1.0.0
3318port: 4052
3319build_dir: ./
3320openapi:
3321  enabled: true
3322  auto_detect_services: false
3323services:
3324  - name: athena
3325    target: rust
3326    branch: main
3327    port: 4052
3328    openapi: null
3329openapi: null
3330workers:
3331  - name: athena-auth
3332    root: services/athena-auth
3333"#;
3334
3335        let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
3336            .expect("duplicate openapi key should be healed");
3337        assert_eq!(config.project_name, "athena");
3338        assert_eq!(
3339            config.openapi.as_ref().and_then(|o| o.enabled),
3340            Some(true)
3341        );
3342        assert!(config.workers.is_some());
3343        let healed = healed_content.expect("duplicate key should trigger rewrite");
3344        let top_level_openapi_lines = healed
3345            .lines()
3346            .filter(|line| line.starts_with("openapi:"))
3347            .count();
3348        assert_eq!(
3349            top_level_openapi_lines, 1,
3350            "healed yaml should keep a single top-level openapi block: {healed}"
3351        );
3352        assert!(
3353            !healed.contains("\nopenapi: null\n") && !healed.ends_with("openapi: null"),
3354            "stray top-level openapi: null must be dropped: {healed}"
3355        );
3356    }
3357
3358    #[test]
3359    fn heals_duplicate_services_keeping_richer_column0_sequence() {
3360        // Matches corrupted monorepo merges: stub services, then full list using
3361        // column-0 `- name:` items (common serde_yaml / hand-edit style).
3362        let yaml = r#"
3363project_name: athena
3364version: 4.0.2
3365port: 4052
3366build_dir: ./
3367environment: {}
3368services:
3369- name: athena
3370  target: docker-compose
3371  branch: main
3372  port: 3001
3373  npm:
3374    enabled: true
3375    package_name: '@xylex-group/athena'
3376release_disabled: []
3377services:
3378- branch: main
3379  name: athena
3380  target: docker-compose
3381  port: 3001
3382  commands:
3383    build: cargo build --release
3384    start: cargo run
3385- branch: main
3386  name: athena-studio
3387  target: nextjs
3388  port: 3000
3389version: 4.0.2
3390release_disabled: []
3391"#;
3392
3393        let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
3394            .expect("duplicate services with column-0 list items should heal");
3395        assert_eq!(config.project_name, "athena");
3396        let services = config.services.expect("services should load");
3397        assert_eq!(services.len(), 2, "should keep the fuller services list");
3398        assert_eq!(services[0].name, "athena");
3399        assert_eq!(services[1].name, "athena-studio");
3400        let healed = healed_content.expect("duplicate services should rewrite");
3401        assert_eq!(
3402            healed.lines().filter(|line| line.starts_with("services:")).count(),
3403            1,
3404            "exactly one services key: {healed}"
3405        );
3406        assert!(healed.contains("athena-studio"));
3407        assert!(
3408            !healed.contains("package_name: '@xylex-group/athena'")
3409                || healed.contains("athena-studio"),
3410            "richer second services block must win"
3411        );
3412    }
3413
3414    #[test]
3415    fn heals_unquoted_colon_command_keys_and_broken_service_items() {
3416        let yaml = r#"
3417project_name: demo
3418version: 1.0.0
3419port: 3000
3420build_dir: ./
3421services:
3422- name: api
3423  target: rust
3424  branch: main
3425  port: 3001
3426  commands:
3427    build: cargo build
3428    deploy:production: cargo run --release
3429- branch: main
3430    format: npm run format
3431    lint: npm run lint
3432- name: web
3433  target: nextjs
3434  branch: main
3435  port: 3000
3436  commands:
3437    start: pnpm start
3438"#;
3439
3440        let (config, healed) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
3441            .expect("colon keys + broken service item should heal");
3442        let services = config.services.expect("services");
3443        assert_eq!(services.len(), 2);
3444        assert_eq!(services[0].name, "api");
3445        assert_eq!(services[1].name, "web");
3446        let commands = services[0].commands.as_ref().expect("api commands");
3447        assert!(
3448            commands.custom.contains_key("deploy:production"),
3449            "quoted colon key should load as deploy:production: {commands:?}"
3450        );
3451        let healed = healed.expect("should rewrite healed yaml");
3452        assert!(healed.contains("\"deploy:production\"") || healed.contains("deploy:production"));
3453        assert!(!healed.contains("\n    format: npm run format\n"));
3454    }
3455
3456    #[test]
3457    fn merges_multi_document_yaml_and_rewrites_single_document() {
3458        let yaml = r#"
3459project_name: demo
3460port: 3000
3461build_dir: $HOME/demo
3462---
3463environment:
3464  LOG_LEVEL: info
3465---
3466services:
3467  - name: api
3468    target: rust
3469    branch: main
3470    port: 3001
3471"#;
3472
3473        let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
3474            .expect("multi-document yaml should merge");
3475
3476        assert_eq!(config.project_name, "demo");
3477        assert_eq!(config.port, 3000);
3478        let environment = config.environment.expect("merged environment should exist");
3479        assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
3480        let services = config.services.expect("merged services should exist");
3481        assert_eq!(services.len(), 1);
3482        assert_eq!(services[0].name, "api");
3483
3484        let healed = healed_content.expect("multi-doc should trigger rewrite");
3485        assert!(
3486            !healed.lines().any(|line| line.trim() == "---"),
3487            "healed yaml should be a single document"
3488        );
3489        assert!(healed.contains("project_name: demo"));
3490        assert!(healed.contains("LOG_LEVEL"));
3491        assert!(healed.contains("name: api"));
3492    }
3493
3494    #[test]
3495    fn parses_bom_prefixed_multi_document_yaml() {
3496        let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: ./\n---\nversion: 1.2.3\n";
3497
3498        let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
3499            .expect("BOM + multi-doc yaml should parse");
3500
3501        assert_eq!(config.project_name, "demo");
3502        assert_eq!(config.version, "1.2.3");
3503        assert!(healed_content.is_some());
3504    }
3505
3506    #[test]
3507    fn ensure_xbp_yaml_schema_directive_is_idempotent() {
3508        let bare = "project_name: demo\nport: 3000\nbuild_dir: .\n";
3509        let once = super::ensure_xbp_yaml_schema_directive(bare);
3510        assert!(once.starts_with("# yaml-language-server: $schema="));
3511        assert!(once.contains("project_name: demo"));
3512        let twice = super::ensure_xbp_yaml_schema_directive(&once);
3513        assert_eq!(
3514            once.matches("yaml-language-server").count(),
3515            1,
3516            "should not duplicate schema directive"
3517        );
3518        assert_eq!(twice, once);
3519
3520        let with_doc = "---\nproject_name: demo\n";
3521        let headed = super::ensure_xbp_yaml_schema_directive(with_doc);
3522        assert!(headed.starts_with("---\n# yaml-language-server:"));
3523    }
3524
3525    #[test]
3526    fn rewrites_legacy_xbp_repo_schema_url_to_mirrors() {
3527        // Older configs pointed at xbp/main/schemas/...; canonical remote is xbp-mirrors.
3528        let legacy = concat!(
3529            "# yaml-language-server: $schema=https://raw.githubusercontent.com/xylex-group/xbp/main/schemas/xbp.project.schema.json\n",
3530            "project_name: demo\nport: 3000\nbuild_dir: .\n",
3531        );
3532        let fixed = super::ensure_xbp_yaml_schema_directive(legacy);
3533        assert!(
3534            fixed.contains(super::XBP_PROJECT_YAML_SCHEMA_URL),
3535            "should rewrite to xbp-mirrors schema URL: {fixed}"
3536        );
3537        assert!(
3538            fixed.contains("xbp-mirrors"),
3539            "canonical remote is xbp-mirrors: {fixed}"
3540        );
3541        assert!(
3542            !fixed.contains("xylex-group/xbp/main/schemas"),
3543            "legacy repo-path schema URL must be replaced: {fixed}"
3544        );
3545        assert_eq!(fixed.matches("yaml-language-server").count(), 1);
3546
3547        // Already on mirrors: idempotent keep.
3548        let mirrors = concat!(
3549            "# yaml-language-server: $schema=https://raw.githubusercontent.com/xylex-group/xbp-mirrors/refs/heads/main/xbp.project.schema.json\n",
3550            "project_name: demo\nport: 3000\nbuild_dir: .\n",
3551        );
3552        let kept = super::ensure_xbp_yaml_schema_directive(mirrors);
3553        assert_eq!(kept, mirrors);
3554    }
3555
3556    #[test]
3557    fn local_schema_directive_for_dot_xbp_config() {
3558        let root = make_temp_path("local-schema-dir");
3559        let _ = fs::remove_dir_all(&root);
3560        fs::create_dir_all(root.join("schemas")).expect("schemas");
3561        fs::create_dir_all(root.join(".xbp")).expect(".xbp");
3562        fs::write(
3563            root.join("schemas").join("xbp.project.schema.json"),
3564            r#"{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}"#,
3565        )
3566        .expect("schema file");
3567        let config_path = root.join(".xbp").join("xbp.yaml");
3568        let directive = super::xbp_project_schema_directive_for_path(&config_path);
3569        assert!(
3570            directive.contains("../schemas/xbp.project.schema.json")
3571                || directive.contains("schemas/xbp.project.schema.json"),
3572            "expected relative local schema, got {directive}"
3573        );
3574        let _ = fs::remove_dir_all(&root);
3575    }
3576
3577    #[test]
3578    fn detect_heal_opportunities_reports_bom_and_multidoc() {
3579        let yaml = "\u{feff}project_name: demo\n---\nsystemd: athena.service\n";
3580        let fixes = detect_xbp_config_heal_opportunities(yaml);
3581        assert!(
3582            fixes.iter().any(|fix| fix.contains("UTF-8 BOM")),
3583            "expected BOM fix, got {fixes:?}"
3584        );
3585        assert!(
3586            fixes.iter().any(|fix| fix.contains("multi-document")),
3587            "expected multi-doc fix, got {fixes:?}"
3588        );
3589        assert!(
3590            fixes.iter().any(|fix| fix.contains("systemd")),
3591            "expected systemd fix, got {fixes:?}"
3592        );
3593    }
3594
3595    #[test]
3596    fn heal_config_file_strips_bom_on_disk() {
3597        let project_root = make_temp_path("heal-bom-yaml");
3598        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
3599        let yaml_path = project_root.join(".xbp").join("xbp.yaml");
3600        fs::write(
3601            &yaml_path,
3602            "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
3603        )
3604        .expect("yaml should be written with BOM");
3605
3606        let healed = super::heal_config_file(&yaml_path, "yaml").expect("heal should succeed");
3607        assert!(healed, "BOM config should be rewritten");
3608
3609        let rewritten = fs::read_to_string(&yaml_path).expect("healed yaml should be readable");
3610        assert!(
3611            !rewritten.starts_with('\u{feff}'),
3612            "rewritten file must not start with BOM"
3613        );
3614        assert!(rewritten.contains("project_name: demo"));
3615
3616        // Second pass is a no-op once the file is clean (unless field heals apply).
3617        let (config, second_heal) =
3618            parse_config_with_auto_heal::<XbpConfig>(&rewritten, "yaml").expect("reparse");
3619        assert_eq!(config.project_name, "demo");
3620        // Environment/systemd heals may not apply; structural heal should be false.
3621        // Field heals are also false for this minimal fixture.
3622        assert!(second_heal.is_none());
3623
3624        fs::remove_dir_all(project_root).expect("temp project should be removed");
3625    }
3626
3627    #[test]
3628    fn heals_nested_yaml_environment_blocks() {
3629        let yaml = r#"
3630project_name: demo
3631port: 3000
3632build_dir: $HOME/demo
3633environment:
3634  production:
3635    DATABASE_URL: postgres://localhost/demo
3636    LOG_LEVEL: info
3637services:
3638  - name: api
3639    target: rust
3640    branch: main
3641    port: 3001
3642    environment:
3643      production:
3644        SERVICE_TOKEN: abc123
3645"#;
3646
3647        let (config, healed_content) =
3648            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
3649
3650        let environment = config.environment.expect("top-level env should exist");
3651        assert_eq!(
3652            environment.get("DATABASE_URL"),
3653            Some(&"postgres://localhost/demo".to_string())
3654        );
3655        assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
3656
3657        let service_environment = config.services.expect("services should exist")[0]
3658            .environment
3659            .clone()
3660            .expect("service env should exist");
3661        assert_eq!(
3662            service_environment.get("SERVICE_TOKEN"),
3663            Some(&"abc123".to_string())
3664        );
3665        assert!(healed_content.is_some());
3666    }
3667
3668    #[test]
3669    fn detect_xbp_config_heal_opportunities_finds_systemd_shorthand() {
3670        let yaml = r#"
3671services:
3672  - name: api
3673    systemd: athena.service
3674    systemd_service_name: athena
3675"#;
3676        let fixes = detect_xbp_config_heal_opportunities(yaml);
3677        assert_eq!(fixes.len(), 1);
3678        assert!(fixes[0].contains("athena.service"));
3679    }
3680
3681    #[test]
3682    fn heals_systemd_string_shorthand_in_yaml() {
3683        let yaml = r#"
3684project_name: athena
3685port: 3000
3686build_dir: $HOME/athena
3687systemd: athena.service
3688services:
3689  - name: api
3690    target: rust
3691    branch: main
3692    port: 3001
3693    systemd: athena-api.service
3694"#;
3695
3696        let (config, healed_content) =
3697            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
3698
3699        assert_eq!(
3700            config.systemd_service_name.as_deref(),
3701            Some("athena.service")
3702        );
3703        assert!(config.systemd.is_none());
3704
3705        let service = &config.services.expect("services should exist")[0];
3706        assert_eq!(
3707            service.systemd_service_name.as_deref(),
3708            Some("athena-api.service")
3709        );
3710        assert!(service.systemd.is_none());
3711        assert!(healed_content.is_some());
3712        assert!(!healed_content
3713            .expect("healed content should exist")
3714            .contains("systemd: athena"));
3715    }
3716
3717    #[test]
3718    fn heals_systemd_string_shorthand_without_overwriting_existing_service_name() {
3719        let yaml = r#"
3720project_name: athena
3721port: 4052
3722build_dir: $HOME/athena
3723services:
3724  - name: athena-gateway
3725    target: rust
3726    branch: main
3727    port: 4052
3728    systemd: athena.service
3729    systemd_service_name: athena
3730"#;
3731
3732        let (config, healed_content) =
3733            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
3734
3735        let service = &config.services.expect("services should exist")[0];
3736        assert_eq!(service.systemd_service_name.as_deref(), Some("athena"));
3737        assert!(service.systemd.is_none());
3738        assert!(healed_content.is_some());
3739    }
3740
3741    #[test]
3742    fn heals_non_string_environment_values_in_json() {
3743        let json = r#"{
3744  "project_name": "demo",
3745  "port": 3000,
3746  "build_dir": "$HOME/demo",
3747  "environment": {
3748    "PORT": 3000,
3749    "DEBUG": true,
3750    "EMPTY": null
3751  }
3752}"#;
3753
3754        let (config, healed_content) =
3755            parse_config_with_auto_heal::<XbpConfig>(json, "json").expect("config should heal");
3756
3757        let environment = config.environment.expect("top-level env should exist");
3758        assert_eq!(environment.get("PORT"), Some(&"3000".to_string()));
3759        assert_eq!(environment.get("DEBUG"), Some(&"true".to_string()));
3760        assert_eq!(environment.get("EMPTY"), Some(&String::new()));
3761        assert!(healed_content.is_some());
3762    }
3763
3764    #[test]
3765    fn heals_string_bool_fields_in_toml_services() {
3766        // Hand-edited TOML often quotes booleans: target_freeze = "true"
3767        let toml = r#"
3768project_name = "demo"
3769port = 3000
3770build_dir = "./"
3771
3772[[services]]
3773name = "app"
3774target = "rust"
3775target_freeze = "true"
3776branch = "main"
3777port = 3000
3778force_run_from_root = "false"
3779versioning = "yes"
3780release = "0"
3781"#;
3782
3783        let (config, healed_content) =
3784            parse_config_with_auto_heal::<XbpConfig>(toml, "toml").expect("string bools should heal");
3785
3786        let service = &config.services.expect("services")[0];
3787        assert_eq!(service.target_freeze, Some(true));
3788        assert_eq!(service.force_run_from_root, Some(false));
3789        assert_eq!(service.versioning, Some(true));
3790        assert_eq!(service.release, Some(false));
3791        let healed = healed_content.expect("heal should rewrite quoted bools");
3792        assert!(
3793            healed.contains("target_freeze = true")
3794                || healed.contains("target_freeze=true"),
3795            "healed TOML should emit unquoted bools: {healed}"
3796        );
3797        assert!(!healed.contains(r#"target_freeze = "true""#));
3798    }
3799
3800    #[test]
3801    fn heals_yaml_body_saved_as_toml_extension() {
3802        // Real-world failure: YAML content under `.xbp/xbp.toml`.
3803        let yaml_as_toml = r#"# yaml-language-server: $schema=https://example.com/schema.json
3804project_name: athena
3805version: 4.0.2
3806port: 4052
3807build_dir: ./
3808services:
3809  - name: api
3810    target: rust
3811    branch: main
3812    port: 3001
3813"#;
3814
3815        assert_eq!(super::detect_config_content_kind(yaml_as_toml), Some("yaml"));
3816        let fixes = super::detect_xbp_config_heal_opportunities_for_kind(yaml_as_toml, Some("toml"));
3817        assert!(
3818            fixes.iter().any(|f| f.contains("mismatch") || f.contains("yaml")),
3819            "expected format-mismatch fix, got {fixes:?}"
3820        );
3821
3822        let (config, healed_content) =
3823            parse_config_with_auto_heal::<XbpConfig>(yaml_as_toml, "toml")
3824                .expect("yaml-in-toml should recover");
3825        assert_eq!(config.project_name, "athena");
3826        assert_eq!(config.version, "4.0.2");
3827        let healed = healed_content.expect("should rewrite as real TOML");
3828        assert!(
3829            healed.contains("project_name = \"athena\"")
3830                || healed.contains("project_name=\"athena\""),
3831            "healed body must be TOML: {healed}"
3832        );
3833        assert!(
3834            !healed.contains("project_name: athena"),
3835            "YAML key syntax must not remain: {healed}"
3836        );
3837
3838        // Disk heal path.
3839        let dir = make_temp_path("yaml-as-toml");
3840        fs::create_dir_all(dir.join(".xbp")).expect("xbp dir");
3841        let path = dir.join(".xbp").join("xbp.toml");
3842        fs::write(&path, yaml_as_toml).expect("write broken config");
3843        let result = super::heal_config_path(&path, "toml")
3844            .expect("heal")
3845            .expect("should report heal");
3846        assert!(!result.fixes.is_empty());
3847        let rewritten = fs::read_to_string(&path).expect("read healed");
3848        assert!(
3849            rewritten.contains("project_name")
3850                && rewritten.contains('=')
3851                && !rewritten.contains("project_name: athena"),
3852            "disk heal must write TOML: {rewritten}"
3853        );
3854        let _ = fs::remove_dir_all(&dir);
3855    }
3856
3857    #[test]
3858    fn default_project_config_kind_is_toml() {
3859        assert_eq!(super::DEFAULT_PROJECT_CONFIG_KIND, "toml");
3860        assert_eq!(super::DEFAULT_PROJECT_CONFIG_RELATIVE, ".xbp/xbp.toml");
3861        let path = default_project_config_path(std::path::Path::new("/tmp/proj"), "toml");
3862        assert!(path.ends_with(".xbp/xbp.toml") || path.ends_with(r".xbp\xbp.toml"));
3863        assert_eq!(
3864            super::project_config_display_path(std::path::Path::new("/tmp/no-config-here")),
3865            ".xbp/xbp.toml"
3866        );
3867    }
3868
3869    #[test]
3870    fn command_helpers_respect_path_order() {
3871        let bin_dir = make_temp_path("bin");
3872        fs::create_dir_all(&bin_dir).expect("temp dir should be created");
3873        fs::write(bin_dir.join("python"), b"").expect("python file should be created");
3874        fs::write(bin_dir.join("pip"), b"").expect("pip file should be created");
3875
3876        with_path(std::slice::from_ref(&bin_dir), || {
3877            assert!(command_exists("python"));
3878            assert_eq!(
3879                first_available_command(&["python3", "python"]),
3880                Some("python".to_string())
3881            );
3882            assert_eq!(preferred_python_command(), "python".to_string());
3883            assert_eq!(preferred_pip_command(), "pip".to_string());
3884        });
3885
3886        fs::remove_dir_all(&bin_dir).expect("temp dir should be removed");
3887    }
3888
3889    #[test]
3890    fn resolves_xbp_project_for_nested_paths() {
3891        let project_root = make_temp_path("ownership");
3892        let service_dir = project_root.join("services").join("api");
3893        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
3894        fs::create_dir_all(&service_dir).expect("service dir should be created");
3895        fs::write(
3896            project_root.join(".xbp").join("xbp.json"),
3897            br#"{"project_name":"demo"}"#,
3898        )
3899        .expect("xbp config should be written");
3900
3901        let known_projects = vec![KnownXbpProject {
3902            root: project_root.clone(),
3903            name: "demo".to_string(),
3904        }];
3905
3906        assert_eq!(
3907            resolve_xbp_project_for_path(&service_dir, &known_projects),
3908            Some("demo".to_string())
3909        );
3910
3911        fs::remove_dir_all(&project_root).expect("temp project should be removed");
3912    }
3913
3914    #[test]
3915    fn ignores_non_xbp_paths_for_project_resolution() {
3916        let project_root = make_temp_path("ownership-miss");
3917        let other_dir = make_temp_path("ownership-other");
3918        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
3919        fs::create_dir_all(&other_dir).expect("other dir should be created");
3920        fs::write(
3921            project_root.join(".xbp").join("xbp.json"),
3922            br#"{"project_name":"demo"}"#,
3923        )
3924        .expect("xbp config should be written");
3925
3926        let known_projects = vec![KnownXbpProject {
3927            root: project_root.clone(),
3928            name: "demo".to_string(),
3929        }];
3930
3931        assert_eq!(
3932            resolve_xbp_project_for_path(&other_dir, &known_projects),
3933            None
3934        );
3935
3936        fs::remove_dir_all(&project_root).expect("temp project should be removed");
3937        fs::remove_dir_all(&other_dir).expect("temp other dir should be removed");
3938    }
3939
3940    #[test]
3941    fn auto_converts_legacy_json_to_yaml() {
3942        // Legacy name: conversion is intentionally a no-op. JSON-only configs stay JSON.
3943        let project_root = make_temp_path("json-to-yaml");
3944        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
3945        let json_path = project_root.join(".xbp").join("xbp.json");
3946        fs::write(
3947            &json_path,
3948            br#"{"project_name":"demo","port":3000,"build_dir":"$HOME/demo"}"#,
3949        )
3950        .expect("json should be written");
3951
3952        let output = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &json_path)
3953            .expect("resolution should succeed")
3954            .expect("existing json path should be returned");
3955        assert_eq!(output, json_path, "should keep the existing JSON path");
3956        assert!(output.exists(), "json config should still exist");
3957        assert!(
3958            !project_root.join(".xbp").join("xbp.yaml").exists(),
3959            "must not force-create YAML from JSON"
3960        );
3961
3962        let json = fs::read_to_string(output).expect("json should be readable");
3963        assert!(json.contains("\"project_name\":\"demo\"") || json.contains("\"project_name\": \"demo\""));
3964
3965        fs::remove_dir_all(project_root).expect("temp project should be removed");
3966    }
3967
3968    #[test]
3969    fn normalizes_environment_quotes_in_yaml_output() {
3970        let yaml = super::normalize_xbp_yaml_environment_quotes(
3971            r#"project_name: demo
3972port: 3000
3973build_dir: ./
3974environment:
3975  API_PATH: /api/auth
3976  WITH_DOUBLE: 'look "here"'
3977  WITH_SINGLE: "can't fail"
3978services:
3979  - name: api
3980    target: rust
3981    branch: main
3982    port: 3001
3983    environment:
3984      CALLBACK_PATH: /api/auth/callback
3985      CALLBACK_TITLE: "the 'quoted' callback"
3986"#,
3987        );
3988
3989        assert!(yaml.contains("API_PATH: \"/api/auth\""));
3990        assert!(yaml.contains("WITH_DOUBLE: 'look \"here\"'"));
3991        assert!(yaml.contains("WITH_SINGLE: \"can't fail\""));
3992        assert!(yaml.contains("CALLBACK_PATH: \"/api/auth/callback\""));
3993        assert!(yaml.contains("CALLBACK_TITLE: \"the 'quoted' callback\""));
3994    }
3995
3996    #[test]
3997    fn writes_json_from_yaml_config() {
3998        let project_root = make_temp_path("yaml-to-json");
3999        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
4000        let yaml_path = project_root.join(".xbp").join("xbp.yaml");
4001        let json_out = project_root.join("xbp.json");
4002        fs::write(
4003            &yaml_path,
4004            "project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
4005        )
4006        .expect("yaml should be written");
4007
4008        write_json_config_from_any_xbp_config(&yaml_path, &json_out)
4009            .expect("json should be generated from yaml");
4010
4011        let json = fs::read_to_string(json_out).expect("json should be readable");
4012        assert!(json.contains("\"project_name\": \"demo\""));
4013
4014        fs::remove_dir_all(project_root).expect("temp project should be removed");
4015    }
4016
4017    #[test]
4018    fn parses_github_repo_from_supported_remote_urls() {
4019        assert_eq!(
4020            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
4021            Some(("xylex-group".to_string(), "xbp".to_string()))
4022        );
4023        assert_eq!(
4024            parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
4025            Some(("xylex-group".to_string(), "xbp".to_string()))
4026        );
4027        assert_eq!(
4028            parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
4029            Some(("xylex-group".to_string(), "xbp".to_string()))
4030        );
4031        assert_eq!(
4032            parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
4033            None
4034        );
4035        assert_eq!(
4036            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp/extra.git"),
4037            None
4038        );
4039    }
4040
4041    #[test]
4042    fn redacts_credentials_in_remote_urls() {
4043        let redacted = redact_remote_url_credentials("https://token@example.com/xylex-group/xbp");
4044        assert!(redacted.contains("REDACTED"));
4045        assert!(!redacted.contains("token@example.com"));
4046    }
4047
4048    #[test]
4049    fn reads_origin_remote_url_from_git_config_directory() {
4050        let project_root = make_temp_path("git-config-dir");
4051        let git_dir = project_root.join(".git");
4052        fs::create_dir_all(&git_dir).expect("git dir should be created");
4053        fs::write(
4054            git_dir.join("config"),
4055            "[remote \"origin\"]\n\turl = https://github.com/xylex-group/xbp.git\n",
4056        )
4057        .expect("git config should be written");
4058
4059        let remote = git_remote_url_from_metadata(&project_root, "origin")
4060            .expect("git metadata should parse")
4061            .expect("origin remote should exist");
4062        assert_eq!(remote, "https://github.com/xylex-group/xbp.git");
4063
4064        fs::remove_dir_all(project_root).expect("temp project should be removed");
4065    }
4066
4067    #[test]
4068    fn reads_origin_remote_url_from_gitdir_pointer() {
4069        let project_root = make_temp_path("git-config-file");
4070        let nested_git_dir = project_root.join(".git-real");
4071        fs::create_dir_all(&nested_git_dir).expect("gitdir target should be created");
4072        fs::write(project_root.join(".git"), "gitdir: .git-real\n")
4073            .expect("gitdir pointer should be written");
4074        fs::write(
4075            nested_git_dir.join("config"),
4076            "[remote \"origin\"]\n\turl = git@github.com:xylex-group/xbp.git\n",
4077        )
4078        .expect("git config should be written");
4079
4080        let remote = git_remote_url_from_metadata(&project_root, "origin")
4081            .expect("git metadata should parse")
4082            .expect("origin remote should exist");
4083        assert_eq!(remote, "git@github.com:xylex-group/xbp.git");
4084
4085        fs::remove_dir_all(project_root).expect("temp project should be removed");
4086    }
4087
4088    #[test]
4089    fn reads_origin_remote_url_from_ancestor_git_dir() {
4090        // Monorepo package with its own project root, no local .git — walk up.
4091        let repo_root = make_temp_path("git-ancestor-repo");
4092        let package_root = repo_root.join("packages").join("athena-auth-ui");
4093        fs::create_dir_all(&package_root).expect("package dir should be created");
4094        let git_dir = repo_root.join(".git");
4095        fs::create_dir_all(&git_dir).expect("git dir should be created");
4096        fs::write(
4097            git_dir.join("config"),
4098            "[remote \"origin\"]\n\turl = https://github.com/xylex-group/athena.git\n",
4099        )
4100        .expect("git config should be written");
4101
4102        let remote = git_remote_url_from_metadata(&package_root, "origin")
4103            .expect("git metadata should parse")
4104            .expect("origin remote should exist via ancestor .git");
4105        assert_eq!(remote, "https://github.com/xylex-group/athena.git");
4106
4107        fs::remove_dir_all(repo_root).expect("temp project should be removed");
4108    }
4109
4110    #[test]
4111    fn durable_object_config_round_trips_yaml_json_jsonc_and_toml() {
4112        let value = serde_json::json!({
4113            "project_name": "worker",
4114            "port": 8787,
4115            "build_dir": ".",
4116            "workers": [{
4117                "name": "auth",
4118                "root": "apps/auth",
4119                "durable_objects": {
4120                    "bindings": [{"name": "AUTH", "class_name": "AuthContainer", "script_name": "auth", "environment": "production"}],
4121                    "migrations": [{"tag": "v1", "new_sqlite_classes": ["AuthContainer"]}],
4122                    "namespaces": [{"id": "namespace-id", "name": "auth_AuthContainer", "script": "auth", "class": "AuthContainer", "use_sqlite": true, "use_containers": true}]
4123                },
4124                "containers": [{"class_name": "AuthContainer", "image": "registry.example/auth:latest", "build_context": ".", "instance_type": "standard", "max_instances": 2, "regions": ["WEUR"]}]
4125            }]
4126        });
4127        for kind in ["yaml", "json", "jsonc", "toml"] {
4128            let rendered = render_xbp_config_value(&value, kind).expect("render config");
4129            let (parsed, _) =
4130                parse_config_with_auto_heal::<Value>(&rendered, kind).expect("parse config");
4131            assert_eq!(
4132                parsed["workers"][0]["durable_objects"]["bindings"][0]["class_name"],
4133                "AuthContainer"
4134            );
4135            assert_eq!(
4136                parsed["workers"][0]["durable_objects"]["namespaces"][0]["use_sqlite"],
4137                true
4138            );
4139            assert_eq!(parsed["workers"][0]["containers"][0]["max_instances"], 2);
4140        }
4141    }
4142}