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