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;
12
13pub use cargo_manifest::{
14    resolve_cargo_package_version, resolve_cargo_package_version_required,
15    write_cargo_package_version,
16};
17pub use env_files::{
18    first_lookup_value, load_env_lookup, normalize_env_key, normalize_env_value, parse_env_content,
19    parse_env_file, resolve_env_placeholders, strip_utf8_bom, to_env_references,
20    CLOUDFLARE_ACCOUNT_ID_ENV_KEYS,
21};
22pub use node_toolchain::{
23    find_node_toolchain_root, is_node_toolchain_command, node_toolchain_wrapper_path,
24};
25pub use process_monitor_json::{
26    fix_cursor_process_monitor_json, fix_cursor_process_monitor_json_file,
27    CursorProcessMonitorJsonFix,
28};
29pub use project_paths::{collapse_project_path, resolve_project_path};
30pub use version::{fetch_version, increment_version};
31
32use serde::de::DeserializeOwned;
33use serde::Serialize;
34use serde_json::{Map, Value};
35use std::collections::{BTreeMap, HashMap, HashSet};
36use std::fs;
37use std::path::{Path, PathBuf};
38use std::process::Command;
39use sysinfo::{Pid, System};
40
41use crate::profile::find_all_xbp_projects;
42
43#[derive(Debug, Clone)]
44pub struct FoundXbpConfig {
45    pub project_root: PathBuf,
46    pub config_path: PathBuf,
47    pub kind: &'static str,
48    pub location: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct KnownXbpProject {
53    pub root: PathBuf,
54    pub name: String,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq)]
58pub struct ListeningPortOwnership {
59    pub pids: Vec<u32>,
60    pub xbp_projects: Vec<String>,
61}
62
63pub fn default_project_yaml_config_path(project_root: &Path) -> PathBuf {
64    project_root.join(".xbp").join("xbp.yaml")
65}
66
67pub fn find_existing_yaml_xbp_config(project_root: &Path) -> Option<PathBuf> {
68    let candidates = [
69        project_root.join(".xbp").join("xbp.yaml"),
70        project_root.join(".xbp").join("xbp.yml"),
71        project_root.join("xbp.yaml"),
72        project_root.join("xbp.yml"),
73    ];
74
75    candidates.into_iter().find(|candidate| candidate.exists())
76}
77
78pub fn maybe_auto_convert_legacy_xbp_json_to_yaml(
79    project_root: &Path,
80    config_path: &Path,
81) -> Result<Option<PathBuf>, String> {
82    if config_path.file_name() != Some(std::ffi::OsStr::new("xbp.json")) {
83        return Ok(None);
84    }
85
86    if !config_path.exists() {
87        return Ok(None);
88    }
89
90    if let Some(existing_yaml) = find_existing_yaml_xbp_config(project_root) {
91        return Ok(Some(existing_yaml));
92    }
93
94    let content = fs::read_to_string(config_path).map_err(|e| {
95        format!(
96            "Failed to read legacy JSON config {}: {}",
97            config_path.display(),
98            e
99        )
100    })?;
101    let value: Value = serde_json::from_str(&content)
102        .map_err(|e| format!("Failed to parse legacy JSON config: {}", e))?;
103
104    let yaml_path = default_project_yaml_config_path(project_root);
105    if let Some(parent) = yaml_path.parent() {
106        fs::create_dir_all(parent).map_err(|e| {
107            format!(
108                "Failed to create config directory {}: {}",
109                parent.display(),
110                e
111            )
112        })?;
113    }
114
115    let yaml = serialize_xbp_yaml(&value)?;
116    fs::write(&yaml_path, yaml)
117        .map_err(|e| format!("Failed to write YAML config {}: {}", yaml_path.display(), e))?;
118
119    Ok(Some(yaml_path))
120}
121
122pub fn serialize_xbp_yaml<T: Serialize>(value: &T) -> Result<String, String> {
123    let yaml = serde_yaml::to_string(value)
124        .map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
125    Ok(normalize_xbp_yaml_environment_quotes(&yaml))
126}
127
128fn normalize_xbp_yaml_environment_quotes(yaml: &str) -> String {
129    let mut rendered = String::with_capacity(yaml.len());
130    let mut environment_indent: Option<usize> = None;
131
132    for raw_line in yaml.split_inclusive('\n') {
133        let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
134        let newline = if raw_line.ends_with('\n') { "\n" } else { "" };
135        let indent = line
136            .chars()
137            .take_while(|character| *character == ' ')
138            .count();
139
140        if let Some(active_indent) = environment_indent {
141            if !line.trim().is_empty() && indent <= active_indent {
142                environment_indent = None;
143            }
144        }
145
146        if line.trim() == "environment:" {
147            environment_indent = Some(indent);
148            rendered.push_str(line);
149            rendered.push_str(newline);
150            continue;
151        }
152
153        if let Some(active_indent) = environment_indent {
154            if !line.trim().is_empty() && indent > active_indent {
155                if let Some(rewritten) = rewrite_environment_scalar_line(line) {
156                    rendered.push_str(&rewritten);
157                    rendered.push_str(newline);
158                    continue;
159                }
160            }
161        }
162
163        rendered.push_str(line);
164        rendered.push_str(newline);
165    }
166
167    rendered
168}
169
170fn rewrite_environment_scalar_line(line: &str) -> Option<String> {
171    let (prefix, raw_value) = line.split_once(':')?;
172    let value_fragment = raw_value.trim_start();
173    if value_fragment.is_empty() {
174        return Some(format!("{prefix}: \"\""));
175    }
176
177    let parsed =
178        serde_yaml::from_str::<serde_yaml::Value>(&format!("value: {value_fragment}\n")).ok()?;
179    let value = match parsed {
180        serde_yaml::Value::Mapping(mut mapping) => {
181            mapping.remove(serde_yaml::Value::String("value".to_string()))?
182        }
183        _ => return None,
184    };
185
186    let text = match value {
187        serde_yaml::Value::String(text) => text,
188        serde_yaml::Value::Bool(value) => value.to_string(),
189        serde_yaml::Value::Number(value) => value.to_string(),
190        serde_yaml::Value::Null => String::new(),
191        _ => return None,
192    };
193
194    Some(format!(
195        "{prefix}: {}",
196        render_yaml_environment_scalar(&text)
197    ))
198}
199
200fn render_yaml_environment_scalar(value: &str) -> String {
201    if value.is_empty() {
202        return "\"\"".to_string();
203    }
204
205    let contains_control = value
206        .chars()
207        .any(|character| matches!(character, '\n' | '\r' | '\t') || character.is_control());
208
209    if !contains_control && value.contains('"') {
210        let escaped = value.replace('\'', "''");
211        format!("'{escaped}'")
212    } else {
213        format!("\"{}\"", escape_yaml_double_quoted(value))
214    }
215}
216
217fn escape_yaml_double_quoted(value: &str) -> String {
218    let mut escaped = String::with_capacity(value.len());
219
220    for character in value.chars() {
221        match character {
222            '\\' => escaped.push_str("\\\\"),
223            '"' => escaped.push_str("\\\""),
224            '\n' => escaped.push_str("\\n"),
225            '\r' => escaped.push_str("\\r"),
226            '\t' => escaped.push_str("\\t"),
227            character if character.is_control() => {
228                use std::fmt::Write as _;
229                let _ = write!(&mut escaped, "\\u{:04x}", character as u32);
230            }
231            character => escaped.push(character),
232        }
233    }
234
235    escaped
236}
237
238pub fn write_json_config_from_any_xbp_config(
239    config_path: &Path,
240    output_json_path: &Path,
241) -> Result<(), String> {
242    let content = fs::read_to_string(config_path)
243        .map_err(|e| format!("Failed to read config {}: {}", config_path.display(), e))?;
244
245    let kind = if config_path
246        .extension()
247        .and_then(|ext| ext.to_str())
248        .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
249        .unwrap_or(false)
250    {
251        "yaml"
252    } else {
253        "json"
254    };
255
256    let value = if kind == "yaml" {
257        let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content)
258            .map_err(|e| format!("Failed to parse YAML config: {}", e))?;
259        serde_json::to_value(yaml_value)
260            .map_err(|e| format!("Failed to convert YAML config to JSON value: {}", e))?
261    } else {
262        serde_json::from_str::<Value>(&content)
263            .map_err(|e| format!("Failed to parse JSON config: {}", e))?
264    };
265
266    if let Some(parent) = output_json_path.parent() {
267        fs::create_dir_all(parent).map_err(|e| {
268            format!(
269                "Failed to create config directory {}: {}",
270                parent.display(),
271                e
272            )
273        })?;
274    }
275
276    let rendered = serde_json::to_string_pretty(&value)
277        .map_err(|e| format!("Failed to serialize JSON config: {}", e))?;
278    fs::write(output_json_path, rendered).map_err(|e| {
279        format!(
280            "Failed to write JSON config {}: {}",
281            output_json_path.display(),
282            e
283        )
284    })?;
285
286    Ok(())
287}
288
289pub fn find_xbp_config_upwards(start_dir: &Path) -> Option<FoundXbpConfig> {
290    for dir in start_dir.ancestors() {
291        let candidates: [(PathBuf, &'static str); 6] = [
292            (dir.join(".xbp").join("xbp.yaml"), "yaml"),
293            (dir.join(".xbp").join("xbp.yml"), "yaml"),
294            (dir.join(".xbp").join("xbp.json"), "json"),
295            (dir.join("xbp.yaml"), "yaml"),
296            (dir.join("xbp.yml"), "yaml"),
297            (dir.join("xbp.json"), "json"),
298        ];
299
300        for (path, kind) in candidates {
301            if !path.exists() {
302                continue;
303            }
304
305            let project_root = path
306                .parent()
307                .and_then(|p| {
308                    if p.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
309                        p.parent().map(|pp| pp.to_path_buf())
310                    } else {
311                        Some(p.to_path_buf())
312                    }
313                })
314                .unwrap_or_else(|| dir.to_path_buf());
315
316            let location = path
317                .strip_prefix(&project_root)
318                .ok()
319                .map(|p| p.to_string_lossy().replace('\\', "/"))
320                .unwrap_or_else(|| path.to_string_lossy().replace('\\', "/"));
321
322            return Some(FoundXbpConfig {
323                project_root,
324                config_path: path,
325                kind,
326                location,
327            });
328        }
329    }
330
331    None
332}
333
334pub fn collect_known_xbp_projects() -> Vec<KnownXbpProject> {
335    let mut projects = Vec::new();
336    let mut seen_roots = HashSet::new();
337
338    for project in find_all_xbp_projects() {
339        let root = canonicalize_or_fallback(&project.path);
340        if seen_roots.insert(root.clone()) {
341            projects.push(KnownXbpProject {
342                root,
343                name: project.name,
344            });
345        }
346    }
347
348    if let Ok(current_dir) = std::env::current_dir() {
349        if let Some(found) = find_xbp_config_upwards(&current_dir) {
350            let root = canonicalize_or_fallback(&found.project_root);
351            if seen_roots.insert(root.clone()) {
352                let name = root
353                    .file_name()
354                    .and_then(|value| value.to_str())
355                    .unwrap_or("current")
356                    .to_string();
357                projects.push(KnownXbpProject { root, name });
358            }
359        }
360    }
361
362    projects.sort_by(|left, right| {
363        right
364            .root
365            .components()
366            .count()
367            .cmp(&left.root.components().count())
368            .then_with(|| left.name.cmp(&right.name))
369    });
370    projects
371}
372
373pub fn resolve_xbp_project_for_path(
374    candidate: &Path,
375    known_projects: &[KnownXbpProject],
376) -> Option<String> {
377    if candidate.as_os_str().is_empty() {
378        return None;
379    }
380
381    if let Some(found) = find_xbp_config_upwards(candidate) {
382        let found_root = canonicalize_or_fallback(&found.project_root);
383        if let Some(project) = known_projects
384            .iter()
385            .find(|project| canonicalize_or_fallback(&project.root) == found_root)
386        {
387            return Some(project.name.clone());
388        }
389
390        if known_projects.is_empty() {
391            return found_root
392                .file_name()
393                .and_then(|value| value.to_str())
394                .map(|value| value.to_string());
395        }
396    }
397
398    let candidate_path = canonicalize_or_fallback(candidate);
399    known_projects
400        .iter()
401        .find(|project| candidate_path.starts_with(canonicalize_or_fallback(&project.root)))
402        .map(|project| project.name.clone())
403}
404
405pub fn collect_listening_port_ownership() -> Result<BTreeMap<u16, ListeningPortOwnership>, String> {
406    use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
407
408    let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
409    let proto_flags = ProtocolFlags::TCP;
410    let sockets = get_sockets_info(af_flags, proto_flags)
411        .map_err(|e| format!("Failed to get sockets info: {}", e))?;
412
413    let mut system = System::new_all();
414    system.refresh_all();
415    let known_projects = collect_known_xbp_projects();
416    let mut pid_project_cache: HashMap<u32, Option<String>> = HashMap::new();
417    let mut ports: BTreeMap<u16, ListeningPortOwnership> = BTreeMap::new();
418
419    for socket in sockets {
420        if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
421            let state = format!("{:?}", tcp.state);
422            if state != "Listen" && state != "LISTEN" {
423                continue;
424            }
425
426            let row = ports.entry(tcp.local_port).or_default();
427            for pid in socket.associated_pids {
428                row.pids.push(pid);
429                if let Some(project) = pid_project_cache
430                    .entry(pid)
431                    .or_insert_with(|| resolve_xbp_project_for_pid(pid, &system, &known_projects))
432                    .clone()
433                {
434                    row.xbp_projects.push(project);
435                }
436            }
437        }
438    }
439
440    for row in ports.values_mut() {
441        row.pids.sort_unstable();
442        row.pids.dedup();
443        row.xbp_projects.sort();
444        row.xbp_projects.dedup();
445    }
446
447    Ok(ports)
448}
449
450fn resolve_xbp_project_for_pid(
451    pid: u32,
452    system: &System,
453    known_projects: &[KnownXbpProject],
454) -> Option<String> {
455    let process = system.process(Pid::from_u32(pid))?;
456
457    if let Some(project) = process
458        .cwd()
459        .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
460    {
461        return Some(project);
462    }
463
464    process
465        .exe()
466        .and_then(|path| path.parent())
467        .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
468}
469
470fn canonicalize_or_fallback(path: &Path) -> PathBuf {
471    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
472}
473
474pub fn expand_home_in_string(input: &str) -> String {
475    let home = dirs::home_dir()
476        .unwrap_or_else(|| std::path::PathBuf::from("."))
477        .to_string_lossy()
478        .to_string();
479
480    if input == "~" {
481        return home;
482    }
483
484    if let Some(rest) = input
485        .strip_prefix("~/")
486        .or_else(|| input.strip_prefix("~\\"))
487    {
488        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
489    }
490
491    if let Some(rest) = input
492        .strip_prefix("$HOME/")
493        .or_else(|| input.strip_prefix("$HOME\\"))
494    {
495        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
496    }
497
498    if let Some(rest) = input
499        .strip_prefix("${HOME}/")
500        .or_else(|| input.strip_prefix("${HOME}\\"))
501    {
502        return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
503    }
504
505    input.to_string()
506}
507
508pub fn collapse_home_to_env(input: &str) -> String {
509    let home = dirs::home_dir()
510        .unwrap_or_else(|| std::path::PathBuf::from("."))
511        .to_string_lossy()
512        .to_string();
513
514    if input == home {
515        return "$HOME".to_string();
516    }
517
518    if let Some(rest) = input.strip_prefix(&(home.clone() + "/")) {
519        return format!("$HOME/{}", rest);
520    }
521
522    if let Some(rest) = input.strip_prefix(&(home.clone() + "\\")) {
523        return format!("$HOME\\{}", rest);
524    }
525
526    input.to_string()
527}
528
529pub fn cargo_program() -> PathBuf {
530    std::env::var_os("CARGO")
531        .map(PathBuf::from)
532        .unwrap_or_else(|| PathBuf::from("cargo"))
533}
534
535pub fn cargo_command_exists() -> bool {
536    let program = cargo_program();
537    if program.is_absolute() {
538        return program.is_file();
539    }
540    command_exists(program.to_string_lossy().as_ref())
541}
542
543pub fn command_exists(program: &str) -> bool {
544    let Some(path_var) = std::env::var_os("PATH") else {
545        return false;
546    };
547
548    for dir in std::env::split_paths(&path_var) {
549        let candidate = dir.join(program);
550        if candidate.is_file() {
551            return true;
552        }
553
554        #[cfg(windows)]
555        for ext in ["exe", "cmd", "bat"] {
556            let candidate = dir.join(format!("{}.{}", program, ext));
557            if candidate.is_file() {
558                return true;
559            }
560        }
561    }
562
563    false
564}
565
566pub fn git_remote_url_from_metadata(
567    project_root: &Path,
568    remote: &str,
569) -> Result<Option<String>, String> {
570    let Some(git_dir) = resolve_git_dir(project_root)? else {
571        return Ok(None);
572    };
573
574    let config_path = git_dir.join("config");
575    if !config_path.exists() {
576        return Ok(None);
577    }
578
579    let content = fs::read_to_string(&config_path)
580        .map_err(|e| format!("Failed to read git config {}: {}", config_path.display(), e))?;
581
582    Ok(parse_git_remote_url_from_config(&content, remote))
583}
584
585pub fn parse_github_repo_from_remote_url(url: &str) -> Option<(String, String)> {
586    let normalized: &str = url.trim();
587
588    let repo_path: String = if let Some(path) = normalized.strip_prefix("git@github.com:") {
589        path.to_string()
590    } else if let Some(path) = parse_github_https_repo_path(normalized) {
591        path
592    } else if let Some(path) = normalized.strip_prefix("ssh://git@github.com/") {
593        path.to_string()
594    } else {
595        return None;
596    };
597
598    let cleaned: &str = repo_path.trim_end_matches('/').trim_end_matches(".git");
599    let mut segments: std::str::Split<'_, char> = cleaned.split('/');
600    let owner: &str = segments.next()?.trim();
601    let repo: &str = segments.next()?.trim();
602    if owner.is_empty() || repo.is_empty() || segments.next().is_some() {
603        return None;
604    }
605
606    Some((owner.to_string(), repo.to_string()))
607}
608
609pub fn redact_remote_url_credentials(url: &str) -> String {
610    if !url.contains('@') || !url.contains("://") {
611        return url.to_string();
612    }
613    let mut parsed: reqwest::Url = match reqwest::Url::parse(url) {
614        Ok(value) => value,
615        Err(_) => return url.to_string(),
616    };
617    if parsed.password().is_some() {
618        let _ = parsed.set_password(Some("REDACTED"));
619    }
620    if !parsed.username().is_empty() {
621        let _ = parsed.set_username("REDACTED");
622    }
623    parsed.to_string()
624}
625
626fn resolve_git_dir(project_root: &Path) -> Result<Option<PathBuf>, String> {
627    let dot_git = project_root.join(".git");
628    if dot_git.is_dir() {
629        return Ok(Some(dot_git));
630    }
631
632    if !dot_git.exists() {
633        return Ok(None);
634    }
635
636    let content = fs::read_to_string(&dot_git)
637        .map_err(|e| format!("Failed to read git metadata {}: {}", dot_git.display(), e))?;
638    let git_dir = content
639        .lines()
640        .find_map(|line| line.trim().strip_prefix("gitdir:").map(str::trim))
641        .filter(|value| !value.is_empty())
642        .ok_or_else(|| format!("Failed to parse gitdir pointer from {}", dot_git.display()))?;
643
644    let git_dir_path = PathBuf::from(git_dir);
645    let resolved = if git_dir_path.is_absolute() {
646        git_dir_path
647    } else {
648        dot_git.parent().unwrap_or(project_root).join(git_dir_path)
649    };
650
651    Ok(Some(resolved))
652}
653
654fn parse_git_remote_url_from_config(content: &str, remote: &str) -> Option<String> {
655    let expected_quoted = format!(r#"remote "{}""#, remote);
656    let expected_dotted = format!("remote.{}", remote);
657    let mut in_target_section = false;
658
659    for line in content.lines() {
660        let trimmed = line.trim();
661        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
662            continue;
663        }
664
665        if trimmed.starts_with('[') && trimmed.ends_with(']') {
666            let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
667            in_target_section = section.eq_ignore_ascii_case(&expected_quoted)
668                || section.eq_ignore_ascii_case(&expected_dotted);
669            continue;
670        }
671
672        if !in_target_section {
673            continue;
674        }
675
676        let Some((key, value)) = trimmed.split_once('=') else {
677            continue;
678        };
679        if key.trim().eq_ignore_ascii_case("url") {
680            let value = value.trim();
681            if !value.is_empty() {
682                return Some(value.to_string());
683            }
684        }
685    }
686
687    None
688}
689
690fn parse_github_https_repo_path(url: &str) -> Option<String> {
691    let parsed: reqwest::Url = reqwest::Url::parse(url).ok()?;
692    if !matches!(parsed.scheme(), "http" | "https") {
693        return None;
694    }
695    if parsed.host_str()?.eq_ignore_ascii_case("github.com") {
696        return Some(parsed.path().trim_start_matches('/').to_string());
697    }
698    None
699}
700
701pub fn first_available_command(candidates: &[&str]) -> Option<String> {
702    candidates
703        .iter()
704        .find(|candidate| command_exists(candidate))
705        .map(|candidate| (*candidate).to_string())
706}
707
708pub fn preferred_python_command() -> String {
709    first_available_command(&["python3", "python"]).unwrap_or_else(|| {
710        if cfg!(target_os = "windows") {
711            "python".to_string()
712        } else {
713            "python3".to_string()
714        }
715    })
716}
717
718pub fn preferred_pip_command() -> String {
719    first_available_command(&["pip3", "pip"]).unwrap_or_else(|| {
720        if cfg!(target_os = "windows") {
721            "pip".to_string()
722        } else {
723            "pip3".to_string()
724        }
725    })
726}
727
728pub fn open_with_default_handler(target: &str) -> Result<(), String> {
729    let mut command = if cfg!(target_os = "windows") {
730        let mut cmd = Command::new("cmd");
731        cmd.arg("/C").arg("start").arg("").arg(target);
732        cmd
733    } else if cfg!(target_os = "macos") {
734        let mut cmd = Command::new("open");
735        cmd.arg(target);
736        cmd
737    } else {
738        let mut cmd = Command::new("xdg-open");
739        cmd.arg(target);
740        cmd
741    };
742
743    command
744        .spawn()
745        .map_err(|e| format!("Failed to open '{}': {}", target, e))?;
746    Ok(())
747}
748
749pub fn open_path_with_editor(path: &Path) -> Result<(), String> {
750    if let Ok(editor) = std::env::var("EDITOR") {
751        let mut parts = editor.split_whitespace();
752        let binary = parts
753            .next()
754            .ok_or_else(|| "EDITOR is set but empty".to_string())?;
755        let mut command = Command::new(binary);
756        for part in parts {
757            command.arg(part);
758        }
759        command
760            .arg(path)
761            .spawn()
762            .map_err(|e| format!("Failed to launch editor '{}': {}", editor, e))?;
763        return Ok(());
764    }
765
766    open_with_default_handler(&path.display().to_string())
767}
768
769pub fn parse_config_with_auto_heal<T: DeserializeOwned>(
770    content: &str,
771    kind: &str,
772) -> Result<(T, Option<String>), String> {
773    let mut value = match kind {
774        "yaml" => {
775            let yaml_value: serde_yaml::Value =
776                serde_yaml::from_str(content).map_err(|e| e.to_string())?;
777            serde_json::to_value(yaml_value).map_err(|e| e.to_string())?
778        }
779        "json" => serde_json::from_str::<Value>(content).map_err(|e| e.to_string())?,
780        _ => return Err(format!("Unsupported config kind: {}", kind)),
781    };
782
783    let healed = auto_heal_xbp_config_value(&mut value);
784    let parsed = serde_json::from_value::<T>(value.clone()).map_err(|e| e.to_string())?;
785
786    let healed_content = if healed {
787        Some(match kind {
788            "yaml" => serialize_xbp_yaml(&value)?,
789            "json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
790            _ => unreachable!(),
791        })
792    } else {
793        None
794    };
795
796    Ok((parsed, healed_content))
797}
798
799#[derive(Debug, Clone, PartialEq, Eq)]
800pub struct XbpConfigHealResult {
801    pub config_path: PathBuf,
802    pub fixes: Vec<String>,
803}
804
805pub fn detect_xbp_config_heal_opportunities(content: &str) -> Vec<String> {
806    let mut fixes = Vec::new();
807
808    for line in content.lines() {
809        let trimmed = line.trim();
810        if !trimmed.starts_with("systemd:") {
811            continue;
812        }
813        let value = trimmed.trim_start_matches("systemd:").trim();
814        if value.is_empty() || value == "null" || value.starts_with('{') {
815            continue;
816        }
817        let normalized = value.trim_matches('"').trim_matches('\'');
818        fixes.push(format!(
819            "normalize `systemd: {normalized}` to `systemd_service_name`"
820        ));
821    }
822
823    fixes.sort();
824    fixes.dedup();
825    fixes
826}
827
828pub fn heal_project_xbp_config(start_dir: &Path) -> Result<Option<XbpConfigHealResult>, String> {
829    let found = match find_xbp_config_upwards(start_dir) {
830        Some(found) => found,
831        None => return Ok(None),
832    };
833
834    let content = fs::read_to_string(&found.config_path)
835        .map_err(|e| format!("Failed to read {}: {}", found.config_path.display(), e))?;
836    let fixes = detect_xbp_config_heal_opportunities(&content);
837    let healed = heal_config_file(&found.config_path, found.kind)?;
838
839    if !healed && fixes.is_empty() {
840        return Ok(None);
841    }
842
843    Ok(Some(XbpConfigHealResult {
844        config_path: found.config_path,
845        fixes: if fixes.is_empty() {
846            vec!["normalized legacy config fields".to_string()]
847        } else {
848            fixes
849        },
850    }))
851}
852
853pub fn heal_config_file(path: &Path, kind: &str) -> Result<bool, String> {
854    let content = fs::read_to_string(path)
855        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
856    let (_, healed_content) = parse_config_with_auto_heal::<Value>(&content, kind)?;
857
858    if let Some(healed_content) = healed_content {
859        fs::write(path, healed_content)
860            .map_err(|e| format!("Failed to write healed config {}: {}", path.display(), e))?;
861        return Ok(true);
862    }
863
864    Ok(false)
865}
866
867pub(crate) fn normalize_xbp_config_value(value: &mut Value) -> bool {
868    auto_heal_xbp_config_value(value)
869}
870
871fn auto_heal_xbp_config_value(value: &mut Value) -> bool {
872    let Some(root) = value.as_object_mut() else {
873        return false;
874    };
875
876    let mut changed = false;
877
878    if let Some(environment) = root.get_mut("environment") {
879        changed |= normalize_environment_value(environment);
880    }
881
882    changed |= normalize_systemd_shorthand(root);
883
884    if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
885        for service in services {
886            let Some(service) = service.as_object_mut() else {
887                continue;
888            };
889
890            if let Some(environment) = service.get_mut("environment") {
891                changed |= normalize_environment_value(environment);
892            }
893
894            changed |= normalize_systemd_shorthand(service);
895        }
896    }
897
898    changed
899}
900
901fn normalize_systemd_shorthand(map: &mut Map<String, Value>) -> bool {
902    let Some(systemd) = map.get("systemd") else {
903        return false;
904    };
905
906    let Value::String(service_name) = systemd else {
907        return false;
908    };
909
910    let systemd_service_name_missing = map
911        .get("systemd_service_name")
912        .map(|value| value.is_null())
913        .unwrap_or(true);
914
915    if systemd_service_name_missing && !service_name.is_empty() {
916        map.insert(
917            "systemd_service_name".to_string(),
918            Value::String(service_name.clone()),
919        );
920    }
921
922    map.remove("systemd");
923    true
924}
925
926fn normalize_environment_value(value: &mut Value) -> bool {
927    let Value::Object(map) = value else {
928        return false;
929    };
930
931    let original = map.clone();
932    let mut normalized = Map::new();
933    let mut changed = false;
934
935    flatten_environment_entries(&original, &mut normalized, &mut changed);
936
937    if normalized != original {
938        *map = normalized;
939        changed = true;
940    }
941
942    changed
943}
944
945fn flatten_environment_entries(
946    source: &Map<String, Value>,
947    target: &mut Map<String, Value>,
948    changed: &mut bool,
949) {
950    for (key, value) in source {
951        match value {
952            Value::String(string) => {
953                target.insert(key.clone(), Value::String(string.clone()));
954            }
955            Value::Number(number) => {
956                *changed = true;
957                target.insert(key.clone(), Value::String(number.to_string()));
958            }
959            Value::Bool(boolean) => {
960                *changed = true;
961                target.insert(key.clone(), Value::String(boolean.to_string()));
962            }
963            Value::Null => {
964                *changed = true;
965                target.insert(key.clone(), Value::String(String::new()));
966            }
967            Value::Array(items) => {
968                *changed = true;
969                let serialized = serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string());
970                target.insert(key.clone(), Value::String(serialized));
971            }
972            Value::Object(nested) => {
973                *changed = true;
974                flatten_environment_entries(nested, target, changed);
975            }
976        }
977    }
978}
979
980#[cfg(test)]
981mod tests {
982    use super::{
983        command_exists, detect_xbp_config_heal_opportunities, first_available_command,
984        git_remote_url_from_metadata, maybe_auto_convert_legacy_xbp_json_to_yaml,
985        parse_config_with_auto_heal, parse_github_repo_from_remote_url, preferred_pip_command,
986        preferred_python_command, redact_remote_url_credentials, resolve_xbp_project_for_path,
987        write_json_config_from_any_xbp_config, KnownXbpProject,
988    };
989    use crate::strategies::XbpConfig;
990    use std::fs;
991    use std::path::PathBuf;
992    use std::sync::{Mutex, OnceLock};
993
994    fn path_lock() -> &'static Mutex<()> {
995        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
996        LOCK.get_or_init(|| Mutex::new(()))
997    }
998
999    fn make_temp_path(name: &str) -> PathBuf {
1000        let mut path = std::env::temp_dir();
1001        path.push(format!("xbp-test-{}-{}", name, std::process::id()));
1002        path
1003    }
1004
1005    fn with_path<F>(entries: &[PathBuf], test: F)
1006    where
1007        F: FnOnce(),
1008    {
1009        let _guard = path_lock().lock().expect("path lock should be available");
1010        let original = std::env::var_os("PATH");
1011        let joined = std::env::join_paths(entries).expect("PATH entries should join");
1012        std::env::set_var("PATH", joined);
1013        test();
1014        match original {
1015            Some(path) => std::env::set_var("PATH", path),
1016            None => std::env::remove_var("PATH"),
1017        }
1018    }
1019
1020    #[test]
1021    fn heals_nested_yaml_environment_blocks() {
1022        let yaml = r#"
1023project_name: demo
1024port: 3000
1025build_dir: $HOME/demo
1026environment:
1027  production:
1028    DATABASE_URL: postgres://localhost/demo
1029    LOG_LEVEL: info
1030services:
1031  - name: api
1032    target: rust
1033    branch: main
1034    port: 3001
1035    environment:
1036      production:
1037        SERVICE_TOKEN: abc123
1038"#;
1039
1040        let (config, healed_content) =
1041            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1042
1043        let environment = config.environment.expect("top-level env should exist");
1044        assert_eq!(
1045            environment.get("DATABASE_URL"),
1046            Some(&"postgres://localhost/demo".to_string())
1047        );
1048        assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
1049
1050        let service_environment = config.services.expect("services should exist")[0]
1051            .environment
1052            .clone()
1053            .expect("service env should exist");
1054        assert_eq!(
1055            service_environment.get("SERVICE_TOKEN"),
1056            Some(&"abc123".to_string())
1057        );
1058        assert!(healed_content.is_some());
1059    }
1060
1061    #[test]
1062    fn detect_xbp_config_heal_opportunities_finds_systemd_shorthand() {
1063        let yaml = r#"
1064services:
1065  - name: api
1066    systemd: athena.service
1067    systemd_service_name: athena
1068"#;
1069        let fixes = detect_xbp_config_heal_opportunities(yaml);
1070        assert_eq!(fixes.len(), 1);
1071        assert!(fixes[0].contains("athena.service"));
1072    }
1073
1074    #[test]
1075    fn heals_systemd_string_shorthand_in_yaml() {
1076        let yaml = r#"
1077project_name: athena
1078port: 3000
1079build_dir: $HOME/athena
1080systemd: athena.service
1081services:
1082  - name: api
1083    target: rust
1084    branch: main
1085    port: 3001
1086    systemd: athena-api.service
1087"#;
1088
1089        let (config, healed_content) =
1090            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1091
1092        assert_eq!(
1093            config.systemd_service_name.as_deref(),
1094            Some("athena.service")
1095        );
1096        assert!(config.systemd.is_none());
1097
1098        let service = &config.services.expect("services should exist")[0];
1099        assert_eq!(
1100            service.systemd_service_name.as_deref(),
1101            Some("athena-api.service")
1102        );
1103        assert!(service.systemd.is_none());
1104        assert!(healed_content.is_some());
1105        assert!(!healed_content
1106            .expect("healed content should exist")
1107            .contains("systemd: athena"));
1108    }
1109
1110    #[test]
1111    fn heals_systemd_string_shorthand_without_overwriting_existing_service_name() {
1112        let yaml = r#"
1113project_name: athena
1114port: 4052
1115build_dir: $HOME/athena
1116services:
1117  - name: athena-gateway
1118    target: rust
1119    branch: main
1120    port: 4052
1121    systemd: athena.service
1122    systemd_service_name: athena
1123"#;
1124
1125        let (config, healed_content) =
1126            parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1127
1128        let service = &config.services.expect("services should exist")[0];
1129        assert_eq!(service.systemd_service_name.as_deref(), Some("athena"));
1130        assert!(service.systemd.is_none());
1131        assert!(healed_content.is_some());
1132    }
1133
1134    #[test]
1135    fn heals_non_string_environment_values_in_json() {
1136        let json = r#"{
1137  "project_name": "demo",
1138  "port": 3000,
1139  "build_dir": "$HOME/demo",
1140  "environment": {
1141    "PORT": 3000,
1142    "DEBUG": true,
1143    "EMPTY": null
1144  }
1145}"#;
1146
1147        let (config, healed_content) =
1148            parse_config_with_auto_heal::<XbpConfig>(json, "json").expect("config should heal");
1149
1150        let environment = config.environment.expect("top-level env should exist");
1151        assert_eq!(environment.get("PORT"), Some(&"3000".to_string()));
1152        assert_eq!(environment.get("DEBUG"), Some(&"true".to_string()));
1153        assert_eq!(environment.get("EMPTY"), Some(&String::new()));
1154        assert!(healed_content.is_some());
1155    }
1156
1157    #[test]
1158    fn command_helpers_respect_path_order() {
1159        let bin_dir = make_temp_path("bin");
1160        fs::create_dir_all(&bin_dir).expect("temp dir should be created");
1161        fs::write(bin_dir.join("python"), b"").expect("python file should be created");
1162        fs::write(bin_dir.join("pip"), b"").expect("pip file should be created");
1163
1164        with_path(std::slice::from_ref(&bin_dir), || {
1165            assert!(command_exists("python"));
1166            assert_eq!(
1167                first_available_command(&["python3", "python"]),
1168                Some("python".to_string())
1169            );
1170            assert_eq!(preferred_python_command(), "python".to_string());
1171            assert_eq!(preferred_pip_command(), "pip".to_string());
1172        });
1173
1174        fs::remove_dir_all(&bin_dir).expect("temp dir should be removed");
1175    }
1176
1177    #[test]
1178    fn resolves_xbp_project_for_nested_paths() {
1179        let project_root = make_temp_path("ownership");
1180        let service_dir = project_root.join("services").join("api");
1181        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1182        fs::create_dir_all(&service_dir).expect("service dir should be created");
1183        fs::write(
1184            project_root.join(".xbp").join("xbp.json"),
1185            br#"{"project_name":"demo"}"#,
1186        )
1187        .expect("xbp config should be written");
1188
1189        let known_projects = vec![KnownXbpProject {
1190            root: project_root.clone(),
1191            name: "demo".to_string(),
1192        }];
1193
1194        assert_eq!(
1195            resolve_xbp_project_for_path(&service_dir, &known_projects),
1196            Some("demo".to_string())
1197        );
1198
1199        fs::remove_dir_all(&project_root).expect("temp project should be removed");
1200    }
1201
1202    #[test]
1203    fn ignores_non_xbp_paths_for_project_resolution() {
1204        let project_root = make_temp_path("ownership-miss");
1205        let other_dir = make_temp_path("ownership-other");
1206        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1207        fs::create_dir_all(&other_dir).expect("other dir should be created");
1208        fs::write(
1209            project_root.join(".xbp").join("xbp.json"),
1210            br#"{"project_name":"demo"}"#,
1211        )
1212        .expect("xbp config should be written");
1213
1214        let known_projects = vec![KnownXbpProject {
1215            root: project_root.clone(),
1216            name: "demo".to_string(),
1217        }];
1218
1219        assert_eq!(
1220            resolve_xbp_project_for_path(&other_dir, &known_projects),
1221            None
1222        );
1223
1224        fs::remove_dir_all(&project_root).expect("temp project should be removed");
1225        fs::remove_dir_all(&other_dir).expect("temp other dir should be removed");
1226    }
1227
1228    #[test]
1229    fn auto_converts_legacy_json_to_yaml() {
1230        let project_root = make_temp_path("json-to-yaml");
1231        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1232        let json_path = project_root.join(".xbp").join("xbp.json");
1233        fs::write(
1234            &json_path,
1235            br#"{"project_name":"demo","port":3000,"build_dir":"$HOME/demo"}"#,
1236        )
1237        .expect("json should be written");
1238
1239        let output = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &json_path)
1240            .expect("conversion should succeed")
1241            .expect("yaml path should be returned");
1242        assert!(output.exists(), "converted yaml should exist");
1243
1244        let yaml = fs::read_to_string(output).expect("yaml should be readable");
1245        assert!(yaml.contains("project_name: demo"));
1246
1247        fs::remove_dir_all(project_root).expect("temp project should be removed");
1248    }
1249
1250    #[test]
1251    fn normalizes_environment_quotes_in_yaml_output() {
1252        let yaml = super::normalize_xbp_yaml_environment_quotes(
1253            r#"project_name: demo
1254port: 3000
1255build_dir: ./
1256environment:
1257  API_PATH: /api/auth
1258  WITH_DOUBLE: 'look "here"'
1259  WITH_SINGLE: "can't fail"
1260services:
1261  - name: api
1262    target: rust
1263    branch: main
1264    port: 3001
1265    environment:
1266      CALLBACK_PATH: /api/auth/callback
1267      CALLBACK_TITLE: "the 'quoted' callback"
1268"#,
1269        );
1270
1271        assert!(yaml.contains("API_PATH: \"/api/auth\""));
1272        assert!(yaml.contains("WITH_DOUBLE: 'look \"here\"'"));
1273        assert!(yaml.contains("WITH_SINGLE: \"can't fail\""));
1274        assert!(yaml.contains("CALLBACK_PATH: \"/api/auth/callback\""));
1275        assert!(yaml.contains("CALLBACK_TITLE: \"the 'quoted' callback\""));
1276    }
1277
1278    #[test]
1279    fn writes_json_from_yaml_config() {
1280        let project_root = make_temp_path("yaml-to-json");
1281        fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1282        let yaml_path = project_root.join(".xbp").join("xbp.yaml");
1283        let json_out = project_root.join("xbp.json");
1284        fs::write(
1285            &yaml_path,
1286            "project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
1287        )
1288        .expect("yaml should be written");
1289
1290        write_json_config_from_any_xbp_config(&yaml_path, &json_out)
1291            .expect("json should be generated from yaml");
1292
1293        let json = fs::read_to_string(json_out).expect("json should be readable");
1294        assert!(json.contains("\"project_name\": \"demo\""));
1295
1296        fs::remove_dir_all(project_root).expect("temp project should be removed");
1297    }
1298
1299    #[test]
1300    fn parses_github_repo_from_supported_remote_urls() {
1301        assert_eq!(
1302            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
1303            Some(("xylex-group".to_string(), "xbp".to_string()))
1304        );
1305        assert_eq!(
1306            parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
1307            Some(("xylex-group".to_string(), "xbp".to_string()))
1308        );
1309        assert_eq!(
1310            parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
1311            Some(("xylex-group".to_string(), "xbp".to_string()))
1312        );
1313        assert_eq!(
1314            parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
1315            None
1316        );
1317        assert_eq!(
1318            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp/extra.git"),
1319            None
1320        );
1321    }
1322
1323    #[test]
1324    fn redacts_credentials_in_remote_urls() {
1325        let redacted = redact_remote_url_credentials("https://token@example.com/xylex-group/xbp");
1326        assert!(redacted.contains("REDACTED"));
1327        assert!(!redacted.contains("token@example.com"));
1328    }
1329
1330    #[test]
1331    fn reads_origin_remote_url_from_git_config_directory() {
1332        let project_root = make_temp_path("git-config-dir");
1333        let git_dir = project_root.join(".git");
1334        fs::create_dir_all(&git_dir).expect("git dir should be created");
1335        fs::write(
1336            git_dir.join("config"),
1337            "[remote \"origin\"]\n\turl = https://github.com/xylex-group/xbp.git\n",
1338        )
1339        .expect("git config should be written");
1340
1341        let remote = git_remote_url_from_metadata(&project_root, "origin")
1342            .expect("git metadata should parse")
1343            .expect("origin remote should exist");
1344        assert_eq!(remote, "https://github.com/xylex-group/xbp.git");
1345
1346        fs::remove_dir_all(project_root).expect("temp project should be removed");
1347    }
1348
1349    #[test]
1350    fn reads_origin_remote_url_from_gitdir_pointer() {
1351        let project_root = make_temp_path("git-config-file");
1352        let nested_git_dir = project_root.join(".git-real");
1353        fs::create_dir_all(&nested_git_dir).expect("gitdir target should be created");
1354        fs::write(project_root.join(".git"), "gitdir: .git-real\n")
1355            .expect("gitdir pointer should be written");
1356        fs::write(
1357            nested_git_dir.join("config"),
1358            "[remote \"origin\"]\n\turl = git@github.com:xylex-group/xbp.git\n",
1359        )
1360        .expect("git config should be written");
1361
1362        let remote = git_remote_url_from_metadata(&project_root, "origin")
1363            .expect("git metadata should parse")
1364            .expect("origin remote should exist");
1365        assert_eq!(remote, "git@github.com:xylex-group/xbp.git");
1366
1367        fs::remove_dir_all(project_root).expect("temp project should be removed");
1368    }
1369}