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}
783
784impl ConfigParseNormalizeMeta {
785    fn needs_rewrite(&self) -> bool {
786        self.had_bom || self.multi_document
787    }
788}
789
790/// Parse YAML that may include a UTF-8 BOM and/or multiple documents.
791///
792/// Windows editors often write a BOM; `serde_yaml::from_str` then fails with
793/// "deserializing from YAML containing more than one document is not supported".
794/// Multi-document streams (split by `---`) are deep-merged into one mapping so
795/// legacy/split configs still load. Callers rewrite a single clean document.
796fn parse_yaml_config_documents(
797    content: &str,
798) -> Result<(serde_yaml::Value, ConfigParseNormalizeMeta), String> {
799    let had_bom = content.starts_with('\u{feff}');
800    let stripped = strip_utf8_bom(content);
801
802    let mut documents = Vec::new();
803    for document in serde_yaml::Deserializer::from_str(stripped) {
804        let value = serde_yaml::Value::deserialize(document).map_err(|e| e.to_string())?;
805        // Skip empty documents that appear between `---` separators.
806        if !matches!(value, serde_yaml::Value::Null) {
807            documents.push(value);
808        }
809    }
810
811    if documents.is_empty() {
812        return Ok((
813            serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
814            ConfigParseNormalizeMeta {
815                had_bom,
816                multi_document: false,
817            },
818        ));
819    }
820
821    let multi_document = documents.len() > 1;
822    let merged = if multi_document {
823        merge_yaml_values(documents)
824    } else {
825        documents
826            .into_iter()
827            .next()
828            .unwrap_or(serde_yaml::Value::Null)
829    };
830
831    Ok((
832        merged,
833        ConfigParseNormalizeMeta {
834            had_bom,
835            multi_document,
836        },
837    ))
838}
839
840fn merge_yaml_values(documents: Vec<serde_yaml::Value>) -> serde_yaml::Value {
841    let mut merged = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
842    for document in documents {
843        deep_merge_yaml(&mut merged, document);
844    }
845    merged
846}
847
848/// Deep-merge `incoming` into `base`. Mappings merge recursively; sequences and
849/// scalars from later documents replace earlier values.
850fn deep_merge_yaml(base: &mut serde_yaml::Value, incoming: serde_yaml::Value) {
851    match (base, incoming) {
852        (serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(incoming_map)) => {
853            for (key, value) in incoming_map {
854                match base_map.get_mut(&key) {
855                    Some(existing) => deep_merge_yaml(existing, value),
856                    None => {
857                        base_map.insert(key, value);
858                    }
859                }
860            }
861        }
862        (base_slot, incoming) => {
863            *base_slot = incoming;
864        }
865    }
866}
867
868pub fn parse_config_with_auto_heal<T: DeserializeOwned>(
869    content: &str,
870    kind: &str,
871) -> Result<(T, Option<String>), String> {
872    let (mut value, structural_heal) = match kind {
873        "yaml" => {
874            let (yaml_value, meta) = parse_yaml_config_documents(content)?;
875            let json = serde_json::to_value(yaml_value).map_err(|e| e.to_string())?;
876            (json, meta.needs_rewrite())
877        }
878        "json" => {
879            let had_bom = content.starts_with('\u{feff}');
880            let stripped = strip_utf8_bom(content);
881            let json = serde_json::from_str::<Value>(stripped).map_err(|e| e.to_string())?;
882            (json, had_bom)
883        }
884        _ => return Err(format!("Unsupported config kind: {}", kind)),
885    };
886
887    let field_healed = auto_heal_xbp_config_value(&mut value);
888    let healed = field_healed || structural_heal;
889    let parsed = serde_json::from_value::<T>(value.clone()).map_err(|e| e.to_string())?;
890
891    let healed_content = if healed {
892        Some(match kind {
893            "yaml" => serialize_xbp_yaml(&value)?,
894            "json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
895            _ => unreachable!(),
896        })
897    } else {
898        None
899    };
900
901    Ok((parsed, healed_content))
902}
903
904#[derive(Debug, Clone, PartialEq, Eq)]
905pub struct XbpConfigHealResult {
906    pub config_path: PathBuf,
907    pub fixes: Vec<String>,
908}
909
910pub fn detect_xbp_config_heal_opportunities(content: &str) -> Vec<String> {
911    let mut fixes = Vec::new();
912
913    if content.starts_with('\u{feff}') {
914        fixes.push("strip leading UTF-8 BOM".to_string());
915    }
916
917    let stripped = strip_utf8_bom(content);
918    // Count non-empty YAML documents; multi-doc streams are collapsed on load.
919    if content_looks_like_yaml_multidoc(stripped) {
920        fixes.push("collapse multi-document YAML into a single document".to_string());
921    }
922
923    for line in content.lines() {
924        let trimmed = line.trim();
925        if !trimmed.starts_with("systemd:") {
926            continue;
927        }
928        let value = trimmed.trim_start_matches("systemd:").trim();
929        if value.is_empty() || value == "null" || value.starts_with('{') {
930            continue;
931        }
932        let normalized = value.trim_matches('"').trim_matches('\'');
933        fixes.push(format!(
934            "normalize `systemd: {normalized}` to `systemd_service_name`"
935        ));
936    }
937
938    fixes.sort();
939    fixes.dedup();
940    fixes
941}
942
943fn content_looks_like_yaml_multidoc(content: &str) -> bool {
944    let mut document_count = 0usize;
945    for document in serde_yaml::Deserializer::from_str(content) {
946        match serde_yaml::Value::deserialize(document) {
947            Ok(serde_yaml::Value::Null) => {}
948            Ok(_) => {
949                document_count += 1;
950                if document_count > 1 {
951                    return true;
952                }
953            }
954            Err(_) => return false,
955        }
956    }
957    false
958}
959
960pub fn heal_project_xbp_config(start_dir: &Path) -> Result<Option<XbpConfigHealResult>, String> {
961    let found = match find_xbp_config_upwards(start_dir) {
962        Some(found) => found,
963        None => return Ok(None),
964    };
965
966    let content = fs::read_to_string(&found.config_path)
967        .map_err(|e| format!("Failed to read {}: {}", found.config_path.display(), e))?;
968    let fixes = detect_xbp_config_heal_opportunities(&content);
969    let healed = heal_config_file(&found.config_path, found.kind)?;
970
971    if !healed && fixes.is_empty() {
972        return Ok(None);
973    }
974
975    Ok(Some(XbpConfigHealResult {
976        config_path: found.config_path,
977        fixes: if fixes.is_empty() {
978            vec!["normalized legacy config fields".to_string()]
979        } else {
980            fixes
981        },
982    }))
983}
984
985pub fn heal_config_file(path: &Path, kind: &str) -> Result<bool, String> {
986    let content = fs::read_to_string(path)
987        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
988    let (_, healed_content) = parse_config_with_auto_heal::<Value>(&content, kind)?;
989
990    if let Some(healed_content) = healed_content {
991        fs::write(path, healed_content)
992            .map_err(|e| format!("Failed to write healed config {}: {}", path.display(), e))?;
993        return Ok(true);
994    }
995
996    Ok(false)
997}
998
999pub(crate) fn normalize_xbp_config_value(value: &mut Value) -> bool {
1000    auto_heal_xbp_config_value(value)
1001}
1002
1003fn auto_heal_xbp_config_value(value: &mut Value) -> bool {
1004    let Some(root) = value.as_object_mut() else {
1005        return false;
1006    };
1007
1008    let mut changed = false;
1009
1010    if let Some(environment) = root.get_mut("environment") {
1011        changed |= normalize_environment_value(environment);
1012    }
1013
1014    changed |= normalize_systemd_shorthand(root);
1015
1016    if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
1017        for service in services {
1018            let Some(service) = service.as_object_mut() else {
1019                continue;
1020            };
1021
1022            if let Some(environment) = service.get_mut("environment") {
1023                changed |= normalize_environment_value(environment);
1024            }
1025
1026            changed |= normalize_systemd_shorthand(service);
1027        }
1028    }
1029
1030    changed
1031}
1032
1033fn normalize_systemd_shorthand(map: &mut Map<String, Value>) -> bool {
1034    let Some(systemd) = map.get("systemd") else {
1035        return false;
1036    };
1037
1038    let Value::String(service_name) = systemd else {
1039        return false;
1040    };
1041
1042    let systemd_service_name_missing = map
1043        .get("systemd_service_name")
1044        .map(|value| value.is_null())
1045        .unwrap_or(true);
1046
1047    if systemd_service_name_missing && !service_name.is_empty() {
1048        map.insert(
1049            "systemd_service_name".to_string(),
1050            Value::String(service_name.clone()),
1051        );
1052    }
1053
1054    map.remove("systemd");
1055    true
1056}
1057
1058fn normalize_environment_value(value: &mut Value) -> bool {
1059    let Value::Object(map) = value else {
1060        return false;
1061    };
1062
1063    let original = map.clone();
1064    let mut normalized = Map::new();
1065    let mut changed = false;
1066
1067    flatten_environment_entries(&original, &mut normalized, &mut changed);
1068
1069    if normalized != original {
1070        *map = normalized;
1071        changed = true;
1072    }
1073
1074    changed
1075}
1076
1077fn flatten_environment_entries(
1078    source: &Map<String, Value>,
1079    target: &mut Map<String, Value>,
1080    changed: &mut bool,
1081) {
1082    for (key, value) in source {
1083        match value {
1084            Value::String(string) => {
1085                target.insert(key.clone(), Value::String(string.clone()));
1086            }
1087            Value::Number(number) => {
1088                *changed = true;
1089                target.insert(key.clone(), Value::String(number.to_string()));
1090            }
1091            Value::Bool(boolean) => {
1092                *changed = true;
1093                target.insert(key.clone(), Value::String(boolean.to_string()));
1094            }
1095            Value::Null => {
1096                *changed = true;
1097                target.insert(key.clone(), Value::String(String::new()));
1098            }
1099            Value::Array(items) => {
1100                *changed = true;
1101                let serialized = serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string());
1102                target.insert(key.clone(), Value::String(serialized));
1103            }
1104            Value::Object(nested) => {
1105                *changed = true;
1106                flatten_environment_entries(nested, target, changed);
1107            }
1108        }
1109    }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::{
1115        command_exists, detect_xbp_config_heal_opportunities, first_available_command,
1116        git_remote_url_from_metadata, maybe_auto_convert_legacy_xbp_json_to_yaml,
1117        parse_config_with_auto_heal, parse_github_repo_from_remote_url, preferred_pip_command,
1118        preferred_python_command, redact_remote_url_credentials, resolve_xbp_project_for_path,
1119        write_json_config_from_any_xbp_config, KnownXbpProject,
1120    };
1121    use crate::strategies::XbpConfig;
1122    use std::fs;
1123    use std::path::PathBuf;
1124    use std::sync::{Mutex, OnceLock};
1125
1126    fn path_lock() -> &'static Mutex<()> {
1127        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1128        LOCK.get_or_init(|| Mutex::new(()))
1129    }
1130
1131    fn make_temp_path(name: &str) -> PathBuf {
1132        let mut path = std::env::temp_dir();
1133        path.push(format!("xbp-test-{}-{}", name, std::process::id()));
1134        path
1135    }
1136
1137    fn with_path<F>(entries: &[PathBuf], test: F)
1138    where
1139        F: FnOnce(),
1140    {
1141        let _guard = path_lock().lock().expect("path lock should be available");
1142        let original = std::env::var_os("PATH");
1143        let joined = std::env::join_paths(entries).expect("PATH entries should join");
1144        std::env::set_var("PATH", joined);
1145        test();
1146        match original {
1147            Some(path) => std::env::set_var("PATH", path),
1148            None => std::env::remove_var("PATH"),
1149        }
1150    }
1151
1152    #[test]
1153    fn parses_yaml_with_leading_utf8_bom_and_rewrites_clean_file() {
1154        // serde_yaml::from_str fails on BOM with a misleading multi-document error.
1155        let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n";
1156
1157        let (config, healed_content) =
1158            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("BOM yaml should parse");
1159
1160        assert_eq!(config.project_name, "demo");
1161        assert_eq!(config.port, 3000);
1162        let healed = healed_content.expect("BOM should trigger autonomous rewrite");
1163        assert!(
1164            !healed.starts_with('\u{feff}'),
1165            "healed yaml must not retain BOM"
1166        );
1167        assert!(healed.contains("project_name: demo"));
1168    }
1169
1170    #[test]
1171    fn merges_multi_document_yaml_and_rewrites_single_document() {
1172        let yaml = r#"
1173project_name: demo
1174port: 3000
1175build_dir: $HOME/demo
1176---
1177environment:
1178  LOG_LEVEL: info
1179---
1180services:
1181  - name: api
1182    target: rust
1183    branch: main
1184    port: 3001
1185"#;
1186
1187        let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
1188            .expect("multi-document yaml should merge");
1189
1190        assert_eq!(config.project_name, "demo");
1191        assert_eq!(config.port, 3000);
1192        let environment = config.environment.expect("merged environment should exist");
1193        assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
1194        let services = config.services.expect("merged services should exist");
1195        assert_eq!(services.len(), 1);
1196        assert_eq!(services[0].name, "api");
1197
1198        let healed = healed_content.expect("multi-doc should trigger rewrite");
1199        assert!(
1200            !healed.lines().any(|line| line.trim() == "---"),
1201            "healed yaml should be a single document"
1202        );
1203        assert!(healed.contains("project_name: demo"));
1204        assert!(healed.contains("LOG_LEVEL"));
1205        assert!(healed.contains("name: api"));
1206    }
1207
1208    #[test]
1209    fn parses_bom_prefixed_multi_document_yaml() {
1210        let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: ./\n---\nversion: 1.2.3\n";
1211
1212        let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
1213            .expect("BOM + multi-doc yaml should parse");
1214
1215        assert_eq!(config.project_name, "demo");
1216        assert_eq!(config.version, "1.2.3");
1217        assert!(healed_content.is_some());
1218    }
1219
1220    #[test]
1221    fn detect_heal_opportunities_reports_bom_and_multidoc() {
1222        let yaml = "\u{feff}project_name: demo\n---\nsystemd: athena.service\n";
1223        let fixes = detect_xbp_config_heal_opportunities(yaml);
1224        assert!(
1225            fixes.iter().any(|fix| fix.contains("UTF-8 BOM")),
1226            "expected BOM fix, got {fixes:?}"
1227        );
1228        assert!(
1229            fixes.iter().any(|fix| fix.contains("multi-document")),
1230            "expected multi-doc fix, got {fixes:?}"
1231        );
1232        assert!(
1233            fixes.iter().any(|fix| fix.contains("systemd")),
1234            "expected systemd fix, got {fixes:?}"
1235        );
1236    }
1237
1238    #[test]
1239    fn heal_config_file_strips_bom_on_disk() {
1240        let project_root = make_temp_path("heal-bom-yaml");
1241        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1242        let yaml_path = project_root.join(".xbp").join("xbp.yaml");
1243        fs::write(
1244            &yaml_path,
1245            "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
1246        )
1247        .expect("yaml should be written with BOM");
1248
1249        let healed = super::heal_config_file(&yaml_path, "yaml").expect("heal should succeed");
1250        assert!(healed, "BOM config should be rewritten");
1251
1252        let rewritten = fs::read_to_string(&yaml_path).expect("healed yaml should be readable");
1253        assert!(
1254            !rewritten.starts_with('\u{feff}'),
1255            "rewritten file must not start with BOM"
1256        );
1257        assert!(rewritten.contains("project_name: demo"));
1258
1259        // Second pass is a no-op once the file is clean (unless field heals apply).
1260        let (config, second_heal) =
1261            parse_config_with_auto_heal::<XbpConfig>(&rewritten, "yaml").expect("reparse");
1262        assert_eq!(config.project_name, "demo");
1263        // Environment/systemd heals may not apply; structural heal should be false.
1264        // Field heals are also false for this minimal fixture.
1265        assert!(second_heal.is_none());
1266
1267        fs::remove_dir_all(project_root).expect("temp project should be removed");
1268    }
1269
1270    #[test]
1271    fn heals_nested_yaml_environment_blocks() {
1272        let yaml = r#"
1273project_name: demo
1274port: 3000
1275build_dir: $HOME/demo
1276environment:
1277  production:
1278    DATABASE_URL: postgres://localhost/demo
1279    LOG_LEVEL: info
1280services:
1281  - name: api
1282    target: rust
1283    branch: main
1284    port: 3001
1285    environment:
1286      production:
1287        SERVICE_TOKEN: abc123
1288"#;
1289
1290        let (config, healed_content) =
1291            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1292
1293        let environment = config.environment.expect("top-level env should exist");
1294        assert_eq!(
1295            environment.get("DATABASE_URL"),
1296            Some(&"postgres://localhost/demo".to_string())
1297        );
1298        assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
1299
1300        let service_environment = config.services.expect("services should exist")[0]
1301            .environment
1302            .clone()
1303            .expect("service env should exist");
1304        assert_eq!(
1305            service_environment.get("SERVICE_TOKEN"),
1306            Some(&"abc123".to_string())
1307        );
1308        assert!(healed_content.is_some());
1309    }
1310
1311    #[test]
1312    fn detect_xbp_config_heal_opportunities_finds_systemd_shorthand() {
1313        let yaml = r#"
1314services:
1315  - name: api
1316    systemd: athena.service
1317    systemd_service_name: athena
1318"#;
1319        let fixes = detect_xbp_config_heal_opportunities(yaml);
1320        assert_eq!(fixes.len(), 1);
1321        assert!(fixes[0].contains("athena.service"));
1322    }
1323
1324    #[test]
1325    fn heals_systemd_string_shorthand_in_yaml() {
1326        let yaml = r#"
1327project_name: athena
1328port: 3000
1329build_dir: $HOME/athena
1330systemd: athena.service
1331services:
1332  - name: api
1333    target: rust
1334    branch: main
1335    port: 3001
1336    systemd: athena-api.service
1337"#;
1338
1339        let (config, healed_content) =
1340            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1341
1342        assert_eq!(
1343            config.systemd_service_name.as_deref(),
1344            Some("athena.service")
1345        );
1346        assert!(config.systemd.is_none());
1347
1348        let service = &config.services.expect("services should exist")[0];
1349        assert_eq!(
1350            service.systemd_service_name.as_deref(),
1351            Some("athena-api.service")
1352        );
1353        assert!(service.systemd.is_none());
1354        assert!(healed_content.is_some());
1355        assert!(!healed_content
1356            .expect("healed content should exist")
1357            .contains("systemd: athena"));
1358    }
1359
1360    #[test]
1361    fn heals_systemd_string_shorthand_without_overwriting_existing_service_name() {
1362        let yaml = r#"
1363project_name: athena
1364port: 4052
1365build_dir: $HOME/athena
1366services:
1367  - name: athena-gateway
1368    target: rust
1369    branch: main
1370    port: 4052
1371    systemd: athena.service
1372    systemd_service_name: athena
1373"#;
1374
1375        let (config, healed_content) =
1376            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1377
1378        let service = &config.services.expect("services should exist")[0];
1379        assert_eq!(service.systemd_service_name.as_deref(), Some("athena"));
1380        assert!(service.systemd.is_none());
1381        assert!(healed_content.is_some());
1382    }
1383
1384    #[test]
1385    fn heals_non_string_environment_values_in_json() {
1386        let json = r#"{
1387  "project_name": "demo",
1388  "port": 3000,
1389  "build_dir": "$HOME/demo",
1390  "environment": {
1391    "PORT": 3000,
1392    "DEBUG": true,
1393    "EMPTY": null
1394  }
1395}"#;
1396
1397        let (config, healed_content) =
1398            parse_config_with_auto_heal::<XbpConfig>(json, "json").expect("config should heal");
1399
1400        let environment = config.environment.expect("top-level env should exist");
1401        assert_eq!(environment.get("PORT"), Some(&"3000".to_string()));
1402        assert_eq!(environment.get("DEBUG"), Some(&"true".to_string()));
1403        assert_eq!(environment.get("EMPTY"), Some(&String::new()));
1404        assert!(healed_content.is_some());
1405    }
1406
1407    #[test]
1408    fn command_helpers_respect_path_order() {
1409        let bin_dir = make_temp_path("bin");
1410        fs::create_dir_all(&bin_dir).expect("temp dir should be created");
1411        fs::write(bin_dir.join("python"), b"").expect("python file should be created");
1412        fs::write(bin_dir.join("pip"), b"").expect("pip file should be created");
1413
1414        with_path(std::slice::from_ref(&bin_dir), || {
1415            assert!(command_exists("python"));
1416            assert_eq!(
1417                first_available_command(&["python3", "python"]),
1418                Some("python".to_string())
1419            );
1420            assert_eq!(preferred_python_command(), "python".to_string());
1421            assert_eq!(preferred_pip_command(), "pip".to_string());
1422        });
1423
1424        fs::remove_dir_all(&bin_dir).expect("temp dir should be removed");
1425    }
1426
1427    #[test]
1428    fn resolves_xbp_project_for_nested_paths() {
1429        let project_root = make_temp_path("ownership");
1430        let service_dir = project_root.join("services").join("api");
1431        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1432        fs::create_dir_all(&service_dir).expect("service dir should be created");
1433        fs::write(
1434            project_root.join(".xbp").join("xbp.json"),
1435            br#"{"project_name":"demo"}"#,
1436        )
1437        .expect("xbp config should be written");
1438
1439        let known_projects = vec![KnownXbpProject {
1440            root: project_root.clone(),
1441            name: "demo".to_string(),
1442        }];
1443
1444        assert_eq!(
1445            resolve_xbp_project_for_path(&service_dir, &known_projects),
1446            Some("demo".to_string())
1447        );
1448
1449        fs::remove_dir_all(&project_root).expect("temp project should be removed");
1450    }
1451
1452    #[test]
1453    fn ignores_non_xbp_paths_for_project_resolution() {
1454        let project_root = make_temp_path("ownership-miss");
1455        let other_dir = make_temp_path("ownership-other");
1456        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1457        fs::create_dir_all(&other_dir).expect("other dir should be created");
1458        fs::write(
1459            project_root.join(".xbp").join("xbp.json"),
1460            br#"{"project_name":"demo"}"#,
1461        )
1462        .expect("xbp config should be written");
1463
1464        let known_projects = vec![KnownXbpProject {
1465            root: project_root.clone(),
1466            name: "demo".to_string(),
1467        }];
1468
1469        assert_eq!(
1470            resolve_xbp_project_for_path(&other_dir, &known_projects),
1471            None
1472        );
1473
1474        fs::remove_dir_all(&project_root).expect("temp project should be removed");
1475        fs::remove_dir_all(&other_dir).expect("temp other dir should be removed");
1476    }
1477
1478    #[test]
1479    fn auto_converts_legacy_json_to_yaml() {
1480        let project_root = make_temp_path("json-to-yaml");
1481        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1482        let json_path = project_root.join(".xbp").join("xbp.json");
1483        fs::write(
1484            &json_path,
1485            br#"{"project_name":"demo","port":3000,"build_dir":"$HOME/demo"}"#,
1486        )
1487        .expect("json should be written");
1488
1489        let output = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &json_path)
1490            .expect("conversion should succeed")
1491            .expect("yaml path should be returned");
1492        assert!(output.exists(), "converted yaml should exist");
1493
1494        let yaml = fs::read_to_string(output).expect("yaml should be readable");
1495        assert!(yaml.contains("project_name: demo"));
1496
1497        fs::remove_dir_all(project_root).expect("temp project should be removed");
1498    }
1499
1500    #[test]
1501    fn normalizes_environment_quotes_in_yaml_output() {
1502        let yaml = super::normalize_xbp_yaml_environment_quotes(
1503            r#"project_name: demo
1504port: 3000
1505build_dir: ./
1506environment:
1507  API_PATH: /api/auth
1508  WITH_DOUBLE: 'look "here"'
1509  WITH_SINGLE: "can't fail"
1510services:
1511  - name: api
1512    target: rust
1513    branch: main
1514    port: 3001
1515    environment:
1516      CALLBACK_PATH: /api/auth/callback
1517      CALLBACK_TITLE: "the 'quoted' callback"
1518"#,
1519        );
1520
1521        assert!(yaml.contains("API_PATH: \"/api/auth\""));
1522        assert!(yaml.contains("WITH_DOUBLE: 'look \"here\"'"));
1523        assert!(yaml.contains("WITH_SINGLE: \"can't fail\""));
1524        assert!(yaml.contains("CALLBACK_PATH: \"/api/auth/callback\""));
1525        assert!(yaml.contains("CALLBACK_TITLE: \"the 'quoted' callback\""));
1526    }
1527
1528    #[test]
1529    fn writes_json_from_yaml_config() {
1530        let project_root = make_temp_path("yaml-to-json");
1531        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1532        let yaml_path = project_root.join(".xbp").join("xbp.yaml");
1533        let json_out = project_root.join("xbp.json");
1534        fs::write(
1535            &yaml_path,
1536            "project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
1537        )
1538        .expect("yaml should be written");
1539
1540        write_json_config_from_any_xbp_config(&yaml_path, &json_out)
1541            .expect("json should be generated from yaml");
1542
1543        let json = fs::read_to_string(json_out).expect("json should be readable");
1544        assert!(json.contains("\"project_name\": \"demo\""));
1545
1546        fs::remove_dir_all(project_root).expect("temp project should be removed");
1547    }
1548
1549    #[test]
1550    fn parses_github_repo_from_supported_remote_urls() {
1551        assert_eq!(
1552            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
1553            Some(("xylex-group".to_string(), "xbp".to_string()))
1554        );
1555        assert_eq!(
1556            parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
1557            Some(("xylex-group".to_string(), "xbp".to_string()))
1558        );
1559        assert_eq!(
1560            parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
1561            Some(("xylex-group".to_string(), "xbp".to_string()))
1562        );
1563        assert_eq!(
1564            parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
1565            None
1566        );
1567        assert_eq!(
1568            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp/extra.git"),
1569            None
1570        );
1571    }
1572
1573    #[test]
1574    fn redacts_credentials_in_remote_urls() {
1575        let redacted = redact_remote_url_credentials("https://token@example.com/xylex-group/xbp");
1576        assert!(redacted.contains("REDACTED"));
1577        assert!(!redacted.contains("token@example.com"));
1578    }
1579
1580    #[test]
1581    fn reads_origin_remote_url_from_git_config_directory() {
1582        let project_root = make_temp_path("git-config-dir");
1583        let git_dir = project_root.join(".git");
1584        fs::create_dir_all(&git_dir).expect("git dir should be created");
1585        fs::write(
1586            git_dir.join("config"),
1587            "[remote \"origin\"]\n\turl = https://github.com/xylex-group/xbp.git\n",
1588        )
1589        .expect("git config should be written");
1590
1591        let remote = git_remote_url_from_metadata(&project_root, "origin")
1592            .expect("git metadata should parse")
1593            .expect("origin remote should exist");
1594        assert_eq!(remote, "https://github.com/xylex-group/xbp.git");
1595
1596        fs::remove_dir_all(project_root).expect("temp project should be removed");
1597    }
1598
1599    #[test]
1600    fn reads_origin_remote_url_from_gitdir_pointer() {
1601        let project_root = make_temp_path("git-config-file");
1602        let nested_git_dir = project_root.join(".git-real");
1603        fs::create_dir_all(&nested_git_dir).expect("gitdir target should be created");
1604        fs::write(project_root.join(".git"), "gitdir: .git-real\n")
1605            .expect("gitdir pointer should be written");
1606        fs::write(
1607            nested_git_dir.join("config"),
1608            "[remote \"origin\"]\n\turl = git@github.com:xylex-group/xbp.git\n",
1609        )
1610        .expect("git config should be written");
1611
1612        let remote = git_remote_url_from_metadata(&project_root, "origin")
1613            .expect("git metadata should parse")
1614            .expect("origin remote should exist");
1615        assert_eq!(remote, "git@github.com:xylex-group/xbp.git");
1616
1617        fs::remove_dir_all(project_root).expect("temp project should be removed");
1618    }
1619}