Skip to main content

xbp_cli/utils/
mod.rs

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