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_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(¤t_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}