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