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