1pub mod cargo_manifest;
7pub mod env_files;
8pub mod node_toolchain;
9pub mod pnpm_package_json;
10pub mod process_monitor_json;
11pub mod project_paths;
12pub mod tray_runtime;
13pub mod version;
14pub mod xbp_ignore;
15
16pub use cargo_manifest::{
17 resolve_cargo_package_version, resolve_cargo_package_version_required,
18 write_cargo_package_version,
19};
20pub use env_files::{
21 first_lookup_value, load_env_lookup, normalize_env_key, normalize_env_value, parse_env_content,
22 parse_env_file, resolve_env_placeholders, strip_utf8_bom, to_env_references,
23 CLOUDFLARE_ACCOUNT_ID_ENV_KEYS,
24};
25pub use node_toolchain::{
26 find_node_toolchain_root, is_node_toolchain_command, node_toolchain_wrapper_path,
27};
28pub use process_monitor_json::{
29 fix_cursor_process_monitor_json, fix_cursor_process_monitor_json_file,
30 CursorProcessMonitorJsonFix,
31};
32pub use pnpm_package_json::{migrate_package_json_pnpm_settings, PnpmSettingsMigration};
33pub use project_paths::{
34 collapse_project_path, resolve_project_dir, resolve_project_path, resolve_service_root,
35};
36pub use version::{fetch_version, increment_version};
37pub use xbp_ignore::{
38 default_global_worktree_ignore_content, default_xbp_ignore_path,
39 load_global_worktree_ignore_from_path, load_project_xbp_ignore, sync_global_worktree_ignore_file,
40 xbp_ignore_file_candidates, XbpIgnoreSet, DEFAULT_DISCOVERY_SKIP_DIRS,
41 DEFAULT_GLOBAL_WORKTREE_IGNORE_PATTERNS, DEFAULT_NOISE_DIR_NAMES,
42 GLOBAL_WORKTREE_IGNORE_FILENAME,
43};
44
45use serde::de::DeserializeOwned;
46use serde::{Deserialize, Serialize};
47use serde_json::{Map, Value};
48use std::collections::{BTreeMap, HashMap, HashSet};
49use std::fs;
50use std::path::{Path, PathBuf};
51use std::process::Command;
52use sysinfo::{Pid, System};
53
54use crate::profile::find_all_xbp_projects;
55
56#[derive(Debug, Clone)]
57pub struct FoundXbpConfig {
58 pub project_root: PathBuf,
59 pub config_path: PathBuf,
60 pub kind: &'static str,
61 pub location: String,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct KnownXbpProject {
66 pub root: PathBuf,
67 pub name: String,
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct ListeningPortOwnership {
72 pub pids: Vec<u32>,
73 pub xbp_projects: Vec<String>,
74}
75
76pub fn default_project_yaml_config_path(project_root: &Path) -> PathBuf {
77 project_root.join(".xbp").join("xbp.yaml")
78}
79
80pub fn find_existing_yaml_xbp_config(project_root: &Path) -> Option<PathBuf> {
81 let candidates = [
82 project_root.join(".xbp").join("xbp.yaml"),
83 project_root.join(".xbp").join("xbp.yml"),
84 project_root.join("xbp.yaml"),
85 project_root.join("xbp.yml"),
86 ];
87
88 candidates.into_iter().find(|candidate| candidate.exists())
89}
90
91pub fn maybe_auto_convert_legacy_xbp_json_to_yaml(
92 project_root: &Path,
93 config_path: &Path,
94) -> Result<Option<PathBuf>, String> {
95 if config_path.file_name() != Some(std::ffi::OsStr::new("xbp.json")) {
96 return Ok(None);
97 }
98
99 if !config_path.exists() {
100 return Ok(None);
101 }
102
103 if let Some(existing_yaml) = find_existing_yaml_xbp_config(project_root) {
104 return Ok(Some(existing_yaml));
105 }
106
107 let content = fs::read_to_string(config_path).map_err(|e| {
108 format!(
109 "Failed to read legacy JSON config {}: {}",
110 config_path.display(),
111 e
112 )
113 })?;
114 let value: Value = serde_json::from_str(&content)
115 .map_err(|e| format!("Failed to parse legacy JSON config: {}", e))?;
116
117 let yaml_path = default_project_yaml_config_path(project_root);
118 if let Some(parent) = yaml_path.parent() {
119 fs::create_dir_all(parent).map_err(|e| {
120 format!(
121 "Failed to create config directory {}: {}",
122 parent.display(),
123 e
124 )
125 })?;
126 }
127
128 let yaml = serialize_xbp_yaml(&value)?;
129 fs::write(&yaml_path, yaml)
130 .map_err(|e| format!("Failed to write YAML config {}: {}", yaml_path.display(), e))?;
131
132 Ok(Some(yaml_path))
133}
134
135pub const XBP_PROJECT_YAML_SCHEMA_URL: &str =
137 "https://raw.githubusercontent.com/xylex-group/xbp/main/schemas/xbp.project.schema.json";
138
139pub const XBP_PROJECT_YAML_SCHEMA_DIRECTIVE: &str =
141 "# yaml-language-server: $schema=https://raw.githubusercontent.com/xylex-group/xbp/main/schemas/xbp.project.schema.json\n";
142
143pub fn serialize_xbp_yaml<T: Serialize>(value: &T) -> Result<String, String> {
144 let yaml = serde_yaml::to_string(value)
145 .map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
146 let body = normalize_xbp_yaml_environment_quotes(&yaml);
147 Ok(ensure_xbp_yaml_schema_directive(&body))
148}
149
150pub fn ensure_xbp_yaml_schema_directive(yaml: &str) -> String {
152 let trimmed = yaml.trim_start_matches('\u{feff}');
153 if trimmed
154 .lines()
155 .take(8)
156 .any(|line| line.contains("yaml-language-server") && line.contains("$schema"))
157 {
158 return yaml.to_string();
159 }
160 if let Some(rest) = trimmed.strip_prefix("---") {
162 let rest = rest.strip_prefix('\r').unwrap_or(rest);
163 let rest = rest.strip_prefix('\n').unwrap_or(rest);
164 return format!("---\n{XBP_PROJECT_YAML_SCHEMA_DIRECTIVE}{rest}");
165 }
166 format!("{XBP_PROJECT_YAML_SCHEMA_DIRECTIVE}{trimmed}")
167}
168
169fn normalize_xbp_yaml_environment_quotes(yaml: &str) -> String {
170 let mut rendered = String::with_capacity(yaml.len());
171 let mut environment_indent: Option<usize> = None;
172
173 for raw_line in yaml.split_inclusive('\n') {
174 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
175 let newline = if raw_line.ends_with('\n') { "\n" } else { "" };
176 let indent = line
177 .chars()
178 .take_while(|character| *character == ' ')
179 .count();
180
181 if let Some(active_indent) = environment_indent {
182 if !line.trim().is_empty() && indent <= active_indent {
183 environment_indent = None;
184 }
185 }
186
187 if line.trim() == "environment:" {
188 environment_indent = Some(indent);
189 rendered.push_str(line);
190 rendered.push_str(newline);
191 continue;
192 }
193
194 if let Some(active_indent) = environment_indent {
195 if !line.trim().is_empty() && indent > active_indent {
196 if let Some(rewritten) = rewrite_environment_scalar_line(line) {
197 rendered.push_str(&rewritten);
198 rendered.push_str(newline);
199 continue;
200 }
201 }
202 }
203
204 rendered.push_str(line);
205 rendered.push_str(newline);
206 }
207
208 rendered
209}
210
211fn rewrite_environment_scalar_line(line: &str) -> Option<String> {
212 let (prefix, raw_value) = line.split_once(':')?;
213 let value_fragment = raw_value.trim_start();
214 if value_fragment.is_empty() {
215 return Some(format!("{prefix}: \"\""));
216 }
217
218 let parsed =
219 serde_yaml::from_str::<serde_yaml::Value>(&format!("value: {value_fragment}\n")).ok()?;
220 let value = match parsed {
221 serde_yaml::Value::Mapping(mut mapping) => {
222 mapping.remove(serde_yaml::Value::String("value".to_string()))?
223 }
224 _ => return None,
225 };
226
227 let text = match value {
228 serde_yaml::Value::String(text) => text,
229 serde_yaml::Value::Bool(value) => value.to_string(),
230 serde_yaml::Value::Number(value) => value.to_string(),
231 serde_yaml::Value::Null => String::new(),
232 _ => return None,
233 };
234
235 Some(format!(
236 "{prefix}: {}",
237 render_yaml_environment_scalar(&text)
238 ))
239}
240
241fn render_yaml_environment_scalar(value: &str) -> String {
242 if value.is_empty() {
243 return "\"\"".to_string();
244 }
245
246 let contains_control = value
247 .chars()
248 .any(|character| matches!(character, '\n' | '\r' | '\t') || character.is_control());
249
250 if !contains_control && value.contains('"') {
251 let escaped = value.replace('\'', "''");
252 format!("'{escaped}'")
253 } else {
254 format!("\"{}\"", escape_yaml_double_quoted(value))
255 }
256}
257
258fn escape_yaml_double_quoted(value: &str) -> String {
259 let mut escaped = String::with_capacity(value.len());
260
261 for character in value.chars() {
262 match character {
263 '\\' => escaped.push_str("\\\\"),
264 '"' => escaped.push_str("\\\""),
265 '\n' => escaped.push_str("\\n"),
266 '\r' => escaped.push_str("\\r"),
267 '\t' => escaped.push_str("\\t"),
268 character if character.is_control() => {
269 use std::fmt::Write as _;
270 let _ = write!(&mut escaped, "\\u{:04x}", character as u32);
271 }
272 character => escaped.push(character),
273 }
274 }
275
276 escaped
277}
278
279pub fn write_json_config_from_any_xbp_config(
280 config_path: &Path,
281 output_json_path: &Path,
282) -> Result<(), String> {
283 let content = fs::read_to_string(config_path)
284 .map_err(|e| format!("Failed to read config {}: {}", config_path.display(), e))?;
285
286 let kind = if config_path
287 .extension()
288 .and_then(|ext| ext.to_str())
289 .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
290 .unwrap_or(false)
291 {
292 "yaml"
293 } else {
294 "json"
295 };
296
297 let value = if kind == "yaml" {
298 let (yaml_value, _) = parse_yaml_config_documents(&content)
299 .map_err(|e| format!("Failed to parse YAML config: {}", e))?;
300 serde_json::to_value(yaml_value)
301 .map_err(|e| format!("Failed to convert YAML config to JSON value: {}", e))?
302 } else {
303 let stripped = strip_utf8_bom(&content);
304 serde_json::from_str::<Value>(stripped)
305 .map_err(|e| format!("Failed to parse JSON config: {}", e))?
306 };
307
308 if let Some(parent) = output_json_path.parent() {
309 fs::create_dir_all(parent).map_err(|e| {
310 format!(
311 "Failed to create config directory {}: {}",
312 parent.display(),
313 e
314 )
315 })?;
316 }
317
318 let rendered = serde_json::to_string_pretty(&value)
319 .map_err(|e| format!("Failed to serialize JSON config: {}", e))?;
320 fs::write(output_json_path, rendered).map_err(|e| {
321 format!(
322 "Failed to write JSON config {}: {}",
323 output_json_path.display(),
324 e
325 )
326 })?;
327
328 Ok(())
329}
330
331pub fn find_xbp_config_upwards(start_dir: &Path) -> Option<FoundXbpConfig> {
332 for dir in start_dir.ancestors() {
333 let candidates: [(PathBuf, &'static str); 6] = [
334 (dir.join(".xbp").join("xbp.yaml"), "yaml"),
335 (dir.join(".xbp").join("xbp.yml"), "yaml"),
336 (dir.join(".xbp").join("xbp.json"), "json"),
337 (dir.join("xbp.yaml"), "yaml"),
338 (dir.join("xbp.yml"), "yaml"),
339 (dir.join("xbp.json"), "json"),
340 ];
341
342 for (path, kind) in candidates {
343 if !path.exists() {
344 continue;
345 }
346
347 let project_root = path
348 .parent()
349 .and_then(|p| {
350 if p.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
351 p.parent().map(|pp| pp.to_path_buf())
352 } else {
353 Some(p.to_path_buf())
354 }
355 })
356 .unwrap_or_else(|| dir.to_path_buf());
357
358 let location = path
359 .strip_prefix(&project_root)
360 .ok()
361 .map(|p| p.to_string_lossy().replace('\\', "/"))
362 .unwrap_or_else(|| path.to_string_lossy().replace('\\', "/"));
363
364 return Some(FoundXbpConfig {
365 project_root,
366 config_path: path,
367 kind,
368 location,
369 });
370 }
371 }
372
373 None
374}
375
376pub fn collect_known_xbp_projects() -> Vec<KnownXbpProject> {
377 let mut projects = Vec::new();
378 let mut seen_roots = HashSet::new();
379
380 for project in find_all_xbp_projects() {
381 let root = canonicalize_or_fallback(&project.path);
382 if seen_roots.insert(root.clone()) {
383 projects.push(KnownXbpProject {
384 root,
385 name: project.name,
386 });
387 }
388 }
389
390 if let Ok(current_dir) = std::env::current_dir() {
391 if let Some(found) = find_xbp_config_upwards(¤t_dir) {
392 let root = canonicalize_or_fallback(&found.project_root);
393 if seen_roots.insert(root.clone()) {
394 let name = root
395 .file_name()
396 .and_then(|value| value.to_str())
397 .unwrap_or("current")
398 .to_string();
399 projects.push(KnownXbpProject { root, name });
400 }
401 }
402 }
403
404 projects.sort_by(|left, right| {
405 right
406 .root
407 .components()
408 .count()
409 .cmp(&left.root.components().count())
410 .then_with(|| left.name.cmp(&right.name))
411 });
412 projects
413}
414
415pub fn resolve_xbp_project_for_path(
416 candidate: &Path,
417 known_projects: &[KnownXbpProject],
418) -> Option<String> {
419 if candidate.as_os_str().is_empty() {
420 return None;
421 }
422
423 if let Some(found) = find_xbp_config_upwards(candidate) {
424 let found_root = canonicalize_or_fallback(&found.project_root);
425 if let Some(project) = known_projects
426 .iter()
427 .find(|project| canonicalize_or_fallback(&project.root) == found_root)
428 {
429 return Some(project.name.clone());
430 }
431
432 if known_projects.is_empty() {
433 return found_root
434 .file_name()
435 .and_then(|value| value.to_str())
436 .map(|value| value.to_string());
437 }
438 }
439
440 let candidate_path = canonicalize_or_fallback(candidate);
441 known_projects
442 .iter()
443 .find(|project| candidate_path.starts_with(canonicalize_or_fallback(&project.root)))
444 .map(|project| project.name.clone())
445}
446
447pub fn collect_listening_port_ownership() -> Result<BTreeMap<u16, ListeningPortOwnership>, String> {
448 use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
449
450 let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
451 let proto_flags = ProtocolFlags::TCP;
452 let sockets = get_sockets_info(af_flags, proto_flags)
453 .map_err(|e| format!("Failed to get sockets info: {}", e))?;
454
455 let mut system = System::new_all();
456 system.refresh_all();
457 let known_projects = collect_known_xbp_projects();
458 let mut pid_project_cache: HashMap<u32, Option<String>> = HashMap::new();
459 let mut ports: BTreeMap<u16, ListeningPortOwnership> = BTreeMap::new();
460
461 for socket in sockets {
462 if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
463 let state = format!("{:?}", tcp.state);
464 if state != "Listen" && state != "LISTEN" {
465 continue;
466 }
467
468 let row = ports.entry(tcp.local_port).or_default();
469 for pid in socket.associated_pids {
470 row.pids.push(pid);
471 if let Some(project) = pid_project_cache
472 .entry(pid)
473 .or_insert_with(|| resolve_xbp_project_for_pid(pid, &system, &known_projects))
474 .clone()
475 {
476 row.xbp_projects.push(project);
477 }
478 }
479 }
480 }
481
482 for row in ports.values_mut() {
483 row.pids.sort_unstable();
484 row.pids.dedup();
485 row.xbp_projects.sort();
486 row.xbp_projects.dedup();
487 }
488
489 Ok(ports)
490}
491
492fn resolve_xbp_project_for_pid(
493 pid: u32,
494 system: &System,
495 known_projects: &[KnownXbpProject],
496) -> Option<String> {
497 let process = system.process(Pid::from_u32(pid))?;
498
499 if let Some(project) = process
500 .cwd()
501 .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
502 {
503 return Some(project);
504 }
505
506 process
507 .exe()
508 .and_then(|path| path.parent())
509 .and_then(|path| resolve_xbp_project_for_path(path, known_projects))
510}
511
512fn canonicalize_or_fallback(path: &Path) -> PathBuf {
513 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
514}
515
516pub fn expand_home_in_string(input: &str) -> String {
517 let home = dirs::home_dir()
518 .unwrap_or_else(|| std::path::PathBuf::from("."))
519 .to_string_lossy()
520 .to_string();
521
522 if input == "~" {
523 return home;
524 }
525
526 if let Some(rest) = input
527 .strip_prefix("~/")
528 .or_else(|| input.strip_prefix("~\\"))
529 {
530 return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
531 }
532
533 if let Some(rest) = input
534 .strip_prefix("$HOME/")
535 .or_else(|| input.strip_prefix("$HOME\\"))
536 {
537 return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
538 }
539
540 if let Some(rest) = input
541 .strip_prefix("${HOME}/")
542 .or_else(|| input.strip_prefix("${HOME}\\"))
543 {
544 return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
545 }
546
547 input.to_string()
548}
549
550pub fn collapse_home_to_env(input: &str) -> String {
551 let home = dirs::home_dir()
552 .unwrap_or_else(|| std::path::PathBuf::from("."))
553 .to_string_lossy()
554 .to_string();
555
556 if input == home {
557 return "$HOME".to_string();
558 }
559
560 if let Some(rest) = input.strip_prefix(&(home.clone() + "/")) {
561 return format!("$HOME/{}", rest);
562 }
563
564 if let Some(rest) = input.strip_prefix(&(home.clone() + "\\")) {
565 return format!("$HOME\\{}", rest);
566 }
567
568 input.to_string()
569}
570
571pub fn cargo_program() -> PathBuf {
572 std::env::var_os("CARGO")
573 .map(PathBuf::from)
574 .unwrap_or_else(|| PathBuf::from("cargo"))
575}
576
577pub fn cargo_command_exists() -> bool {
578 let program = cargo_program();
579 if program.is_absolute() {
580 return program.is_file();
581 }
582 command_exists(program.to_string_lossy().as_ref())
583}
584
585pub fn command_exists(program: &str) -> bool {
586 let Some(path_var) = std::env::var_os("PATH") else {
587 return false;
588 };
589
590 for dir in std::env::split_paths(&path_var) {
591 let candidate = dir.join(program);
592 if candidate.is_file() {
593 return true;
594 }
595
596 #[cfg(windows)]
597 for ext in ["exe", "cmd", "bat"] {
598 let candidate = dir.join(format!("{}.{}", program, ext));
599 if candidate.is_file() {
600 return true;
601 }
602 }
603 }
604
605 false
606}
607
608pub fn git_remote_url_from_metadata(
609 project_root: &Path,
610 remote: &str,
611) -> Result<Option<String>, String> {
612 let Some(git_dir) = resolve_git_dir(project_root)? else {
613 return Ok(None);
614 };
615
616 let config_path = git_dir.join("config");
617 if !config_path.exists() {
618 return Ok(None);
619 }
620
621 let content = fs::read_to_string(&config_path)
622 .map_err(|e| format!("Failed to read git config {}: {}", config_path.display(), e))?;
623
624 Ok(parse_git_remote_url_from_config(&content, remote))
625}
626
627pub fn parse_github_repo_from_remote_url(url: &str) -> Option<(String, String)> {
628 let normalized: &str = url.trim();
629
630 let repo_path: String = if let Some(path) = normalized.strip_prefix("git@github.com:") {
631 path.to_string()
632 } else if let Some(path) = parse_github_https_repo_path(normalized) {
633 path
634 } else if let Some(path) = normalized.strip_prefix("ssh://git@github.com/") {
635 path.to_string()
636 } else {
637 return None;
638 };
639
640 let cleaned: &str = repo_path.trim_end_matches('/').trim_end_matches(".git");
641 let mut segments: std::str::Split<'_, char> = cleaned.split('/');
642 let owner: &str = segments.next()?.trim();
643 let repo: &str = segments.next()?.trim();
644 if owner.is_empty() || repo.is_empty() || segments.next().is_some() {
645 return None;
646 }
647
648 Some((owner.to_string(), repo.to_string()))
649}
650
651pub fn redact_remote_url_credentials(url: &str) -> String {
652 if !url.contains('@') || !url.contains("://") {
653 return url.to_string();
654 }
655 let mut parsed: reqwest::Url = match reqwest::Url::parse(url) {
656 Ok(value) => value,
657 Err(_) => return url.to_string(),
658 };
659 if parsed.password().is_some() {
660 let _ = parsed.set_password(Some("REDACTED"));
661 }
662 if !parsed.username().is_empty() {
663 let _ = parsed.set_username("REDACTED");
664 }
665 parsed.to_string()
666}
667
668fn resolve_git_dir(project_root: &Path) -> Result<Option<PathBuf>, String> {
669 for dir in project_root.ancestors() {
672 if let Some(git_dir) = resolve_git_dir_at(dir)? {
673 return Ok(Some(git_dir));
674 }
675 }
676 Ok(None)
677}
678
679fn resolve_git_dir_at(dir: &Path) -> Result<Option<PathBuf>, String> {
680 let dot_git = dir.join(".git");
681 if dot_git.is_dir() {
682 return Ok(Some(dot_git));
683 }
684
685 if !dot_git.exists() {
686 return Ok(None);
687 }
688
689 let content = fs::read_to_string(&dot_git)
690 .map_err(|e| format!("Failed to read git metadata {}: {}", dot_git.display(), e))?;
691 let git_dir = content
692 .lines()
693 .find_map(|line| line.trim().strip_prefix("gitdir:").map(str::trim))
694 .filter(|value| !value.is_empty())
695 .ok_or_else(|| format!("Failed to parse gitdir pointer from {}", dot_git.display()))?;
696
697 let git_dir_path = PathBuf::from(git_dir);
698 let resolved = if git_dir_path.is_absolute() {
699 git_dir_path
700 } else {
701 dot_git.parent().unwrap_or(dir).join(git_dir_path)
702 };
703
704 Ok(Some(resolved))
705}
706
707fn parse_git_remote_url_from_config(content: &str, remote: &str) -> Option<String> {
708 let expected_quoted = format!(r#"remote "{}""#, remote);
709 let expected_dotted = format!("remote.{}", remote);
710 let mut in_target_section = false;
711
712 for line in content.lines() {
713 let trimmed = line.trim();
714 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
715 continue;
716 }
717
718 if trimmed.starts_with('[') && trimmed.ends_with(']') {
719 let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
720 in_target_section = section.eq_ignore_ascii_case(&expected_quoted)
721 || section.eq_ignore_ascii_case(&expected_dotted);
722 continue;
723 }
724
725 if !in_target_section {
726 continue;
727 }
728
729 let Some((key, value)) = trimmed.split_once('=') else {
730 continue;
731 };
732 if key.trim().eq_ignore_ascii_case("url") {
733 let value = value.trim();
734 if !value.is_empty() {
735 return Some(value.to_string());
736 }
737 }
738 }
739
740 None
741}
742
743fn parse_github_https_repo_path(url: &str) -> Option<String> {
744 let parsed: reqwest::Url = reqwest::Url::parse(url).ok()?;
745 if !matches!(parsed.scheme(), "http" | "https") {
746 return None;
747 }
748 if parsed.host_str()?.eq_ignore_ascii_case("github.com") {
749 return Some(parsed.path().trim_start_matches('/').to_string());
750 }
751 None
752}
753
754pub fn first_available_command(candidates: &[&str]) -> Option<String> {
755 candidates
756 .iter()
757 .find(|candidate| command_exists(candidate))
758 .map(|candidate| (*candidate).to_string())
759}
760
761pub fn preferred_python_command() -> String {
762 first_available_command(&["python3", "python"]).unwrap_or_else(|| {
763 if cfg!(target_os = "windows") {
764 "python".to_string()
765 } else {
766 "python3".to_string()
767 }
768 })
769}
770
771pub fn preferred_pip_command() -> String {
772 first_available_command(&["pip3", "pip"]).unwrap_or_else(|| {
773 if cfg!(target_os = "windows") {
774 "pip".to_string()
775 } else {
776 "pip3".to_string()
777 }
778 })
779}
780
781pub fn open_with_default_handler(target: &str) -> Result<(), String> {
782 let mut command = if cfg!(target_os = "windows") {
783 let mut cmd = Command::new("cmd");
784 cmd.arg("/C").arg("start").arg("").arg(target);
785 cmd
786 } else if cfg!(target_os = "macos") {
787 let mut cmd = Command::new("open");
788 cmd.arg(target);
789 cmd
790 } else {
791 let mut cmd = Command::new("xdg-open");
792 cmd.arg(target);
793 cmd
794 };
795
796 command
797 .spawn()
798 .map_err(|e| format!("Failed to open '{}': {}", target, e))?;
799 Ok(())
800}
801
802pub fn open_path_with_editor(path: &Path) -> Result<(), String> {
803 if let Ok(editor) = std::env::var("EDITOR") {
804 let mut parts = editor.split_whitespace();
805 let binary = parts
806 .next()
807 .ok_or_else(|| "EDITOR is set but empty".to_string())?;
808 let mut command = Command::new(binary);
809 for part in parts {
810 command.arg(part);
811 }
812 command
813 .arg(path)
814 .spawn()
815 .map_err(|e| format!("Failed to launch editor '{}': {}", editor, e))?;
816 return Ok(());
817 }
818
819 open_with_default_handler(&path.display().to_string())
820}
821
822#[derive(Debug, Clone, Default, PartialEq, Eq)]
824struct ConfigParseNormalizeMeta {
825 had_bom: bool,
827 multi_document: bool,
829 duplicate_top_level_keys: bool,
831}
832
833impl ConfigParseNormalizeMeta {
834 fn needs_rewrite(&self) -> bool {
835 self.had_bom || self.multi_document || self.duplicate_top_level_keys
836 }
837}
838
839fn dedupe_yaml_top_level_keys(content: &str) -> (String, bool) {
846 let mut output = String::with_capacity(content.len());
847 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
848 let mut skipping_duplicate_block = false;
849 let mut changed = false;
850
851 for raw_line in content.split_inclusive('\n') {
852 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
853 let newline = if raw_line.ends_with('\n') { "\n" } else { "" };
854 let trimmed = line.trim_start();
855 let indent = line.len().saturating_sub(trimmed.len());
856
857 if trimmed == "---" || trimmed.starts_with("--- ") {
859 skipping_duplicate_block = false;
860 seen.clear();
861 output.push_str(line);
862 output.push_str(newline);
863 continue;
864 }
865
866 if indent == 0 && !trimmed.is_empty() && !trimmed.starts_with('#') {
867 if let Some(key) = top_level_yaml_key(trimmed) {
869 if !seen.insert(key) {
870 skipping_duplicate_block = true;
872 changed = true;
873 continue;
874 }
875 skipping_duplicate_block = false;
876 } else {
877 skipping_duplicate_block = false;
879 }
880 } else if skipping_duplicate_block {
881 if indent > 0 || trimmed.is_empty() || trimmed.starts_with('#') {
883 changed = true;
884 continue;
885 }
886 skipping_duplicate_block = false;
887 }
888
889 if skipping_duplicate_block {
890 changed = true;
891 continue;
892 }
893
894 output.push_str(line);
895 output.push_str(newline);
896 }
897
898 (output, changed)
899}
900
901fn top_level_yaml_key(trimmed_line: &str) -> Option<String> {
902 if trimmed_line.starts_with('-') {
903 return None;
904 }
905 let without_comment = trimmed_line
906 .split_once(" #")
907 .map(|(left, _)| left)
908 .unwrap_or(trimmed_line)
909 .trim_end();
910 let (key, rest) = without_comment.split_once(':')?;
911 let key = key.trim();
912 if key.is_empty() || key.contains(' ') || key.contains('\t') {
913 return None;
914 }
915 if rest.starts_with("//") {
917 return None;
918 }
919 Some(key.to_string())
920}
921
922fn parse_yaml_config_documents(
931 content: &str,
932) -> Result<(serde_yaml::Value, ConfigParseNormalizeMeta), String> {
933 let had_bom = content.starts_with('\u{feff}');
934 let stripped = strip_utf8_bom(content);
935 let (deduped, duplicate_top_level_keys) = dedupe_yaml_top_level_keys(stripped);
936 let stripped = deduped.as_str();
937
938 let mut documents = Vec::new();
939 for document in serde_yaml::Deserializer::from_str(stripped) {
940 let value = serde_yaml::Value::deserialize(document).map_err(|e| e.to_string())?;
941 if !matches!(value, serde_yaml::Value::Null) {
943 documents.push(value);
944 }
945 }
946
947 if documents.is_empty() {
948 return Ok((
949 serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
950 ConfigParseNormalizeMeta {
951 had_bom,
952 multi_document: false,
953 duplicate_top_level_keys,
954 },
955 ));
956 }
957
958 let multi_document = documents.len() > 1;
959 let merged = if multi_document {
960 merge_yaml_values(documents)
961 } else {
962 documents
963 .into_iter()
964 .next()
965 .unwrap_or(serde_yaml::Value::Null)
966 };
967
968 Ok((
969 merged,
970 ConfigParseNormalizeMeta {
971 had_bom,
972 multi_document,
973 duplicate_top_level_keys,
974 },
975 ))
976}
977
978fn merge_yaml_values(documents: Vec<serde_yaml::Value>) -> serde_yaml::Value {
979 let mut merged = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
980 for document in documents {
981 deep_merge_yaml(&mut merged, document);
982 }
983 merged
984}
985
986fn deep_merge_yaml(base: &mut serde_yaml::Value, incoming: serde_yaml::Value) {
989 match (base, incoming) {
990 (serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(incoming_map)) => {
991 for (key, value) in incoming_map {
992 match base_map.get_mut(&key) {
993 Some(existing) => deep_merge_yaml(existing, value),
994 None => {
995 base_map.insert(key, value);
996 }
997 }
998 }
999 }
1000 (base_slot, incoming) => {
1001 *base_slot = incoming;
1002 }
1003 }
1004}
1005
1006pub fn parse_config_with_auto_heal<T: DeserializeOwned>(
1007 content: &str,
1008 kind: &str,
1009) -> Result<(T, Option<String>), String> {
1010 let (mut value, structural_heal) = match kind {
1011 "yaml" => {
1012 let (yaml_value, meta) = parse_yaml_config_documents(content)?;
1013 let json = serde_json::to_value(yaml_value).map_err(|e| e.to_string())?;
1014 (json, meta.needs_rewrite())
1015 }
1016 "json" => {
1017 let had_bom = content.starts_with('\u{feff}');
1018 let stripped = strip_utf8_bom(content);
1019 let json = serde_json::from_str::<Value>(stripped).map_err(|e| e.to_string())?;
1020 (json, had_bom)
1021 }
1022 _ => return Err(format!("Unsupported config kind: {}", kind)),
1023 };
1024
1025 let field_healed = auto_heal_xbp_config_value(&mut value);
1026 let healed = field_healed || structural_heal;
1027 let parsed = serde_json::from_value::<T>(value.clone()).map_err(|e| e.to_string())?;
1028
1029 let healed_content = if healed {
1030 Some(match kind {
1031 "yaml" => serialize_xbp_yaml(&value)?,
1032 "json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
1033 _ => unreachable!(),
1034 })
1035 } else {
1036 None
1037 };
1038
1039 Ok((parsed, healed_content))
1040}
1041
1042#[derive(Debug, Clone, PartialEq, Eq)]
1043pub struct XbpConfigHealResult {
1044 pub config_path: PathBuf,
1045 pub fixes: Vec<String>,
1046}
1047
1048pub fn detect_xbp_config_heal_opportunities(content: &str) -> Vec<String> {
1049 let mut fixes = Vec::new();
1050
1051 if content.starts_with('\u{feff}') {
1052 fixes.push("strip leading UTF-8 BOM".to_string());
1053 }
1054
1055 let stripped = strip_utf8_bom(content);
1056 if content_looks_like_yaml_multidoc(stripped) {
1058 fixes.push("collapse multi-document YAML into a single document".to_string());
1059 }
1060
1061 for line in content.lines() {
1062 let trimmed = line.trim();
1063 if !trimmed.starts_with("systemd:") {
1064 continue;
1065 }
1066 let value = trimmed.trim_start_matches("systemd:").trim();
1067 if value.is_empty() || value == "null" || value.starts_with('{') {
1068 continue;
1069 }
1070 let normalized = value.trim_matches('"').trim_matches('\'');
1071 fixes.push(format!(
1072 "normalize `systemd: {normalized}` to `systemd_service_name`"
1073 ));
1074 }
1075
1076 fixes.sort();
1077 fixes.dedup();
1078 fixes
1079}
1080
1081fn content_looks_like_yaml_multidoc(content: &str) -> bool {
1082 let mut document_count = 0usize;
1083 for document in serde_yaml::Deserializer::from_str(content) {
1084 match serde_yaml::Value::deserialize(document) {
1085 Ok(serde_yaml::Value::Null) => {}
1086 Ok(_) => {
1087 document_count += 1;
1088 if document_count > 1 {
1089 return true;
1090 }
1091 }
1092 Err(_) => return false,
1093 }
1094 }
1095 false
1096}
1097
1098pub fn heal_project_xbp_config(start_dir: &Path) -> Result<Option<XbpConfigHealResult>, String> {
1099 let found = match find_xbp_config_upwards(start_dir) {
1100 Some(found) => found,
1101 None => return Ok(None),
1102 };
1103
1104 let content = fs::read_to_string(&found.config_path)
1105 .map_err(|e| format!("Failed to read {}: {}", found.config_path.display(), e))?;
1106 let fixes = detect_xbp_config_heal_opportunities(&content);
1107 let healed = heal_config_file(&found.config_path, found.kind)?;
1108
1109 if !healed && fixes.is_empty() {
1110 return Ok(None);
1111 }
1112
1113 Ok(Some(XbpConfigHealResult {
1114 config_path: found.config_path,
1115 fixes: if fixes.is_empty() {
1116 vec!["normalized legacy config fields".to_string()]
1117 } else {
1118 fixes
1119 },
1120 }))
1121}
1122
1123pub fn heal_config_file(path: &Path, kind: &str) -> Result<bool, String> {
1124 let content = fs::read_to_string(path)
1125 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1126 let (_, healed_content) = parse_config_with_auto_heal::<Value>(&content, kind)?;
1127
1128 if let Some(healed_content) = healed_content {
1129 fs::write(path, healed_content)
1130 .map_err(|e| format!("Failed to write healed config {}: {}", path.display(), e))?;
1131 return Ok(true);
1132 }
1133
1134 Ok(false)
1135}
1136
1137pub(crate) fn normalize_xbp_config_value(value: &mut Value) -> bool {
1138 auto_heal_xbp_config_value(value)
1139}
1140
1141fn auto_heal_xbp_config_value(value: &mut Value) -> bool {
1142 let Some(root) = value.as_object_mut() else {
1143 return false;
1144 };
1145
1146 let mut changed = false;
1147
1148 if let Some(environment) = root.get_mut("environment") {
1149 changed |= normalize_environment_value(environment);
1150 }
1151
1152 changed |= normalize_systemd_shorthand(root);
1153
1154 if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
1155 for service in services {
1156 let Some(service) = service.as_object_mut() else {
1157 continue;
1158 };
1159
1160 if let Some(environment) = service.get_mut("environment") {
1161 changed |= normalize_environment_value(environment);
1162 }
1163
1164 changed |= normalize_systemd_shorthand(service);
1165 }
1166 }
1167
1168 changed
1169}
1170
1171fn normalize_systemd_shorthand(map: &mut Map<String, Value>) -> bool {
1172 let Some(systemd) = map.get("systemd") else {
1173 return false;
1174 };
1175
1176 let Value::String(service_name) = systemd else {
1177 return false;
1178 };
1179
1180 let systemd_service_name_missing = map
1181 .get("systemd_service_name")
1182 .map(|value| value.is_null())
1183 .unwrap_or(true);
1184
1185 if systemd_service_name_missing && !service_name.is_empty() {
1186 map.insert(
1187 "systemd_service_name".to_string(),
1188 Value::String(service_name.clone()),
1189 );
1190 }
1191
1192 map.remove("systemd");
1193 true
1194}
1195
1196fn normalize_environment_value(value: &mut Value) -> bool {
1197 let Value::Object(map) = value else {
1198 return false;
1199 };
1200
1201 let original = map.clone();
1202 let mut normalized = Map::new();
1203 let mut changed = false;
1204
1205 flatten_environment_entries(&original, &mut normalized, &mut changed);
1206
1207 if normalized != original {
1208 *map = normalized;
1209 changed = true;
1210 }
1211
1212 changed
1213}
1214
1215fn flatten_environment_entries(
1216 source: &Map<String, Value>,
1217 target: &mut Map<String, Value>,
1218 changed: &mut bool,
1219) {
1220 for (key, value) in source {
1221 match value {
1222 Value::String(string) => {
1223 target.insert(key.clone(), Value::String(string.clone()));
1224 }
1225 Value::Number(number) => {
1226 *changed = true;
1227 target.insert(key.clone(), Value::String(number.to_string()));
1228 }
1229 Value::Bool(boolean) => {
1230 *changed = true;
1231 target.insert(key.clone(), Value::String(boolean.to_string()));
1232 }
1233 Value::Null => {
1234 *changed = true;
1235 target.insert(key.clone(), Value::String(String::new()));
1236 }
1237 Value::Array(items) => {
1238 *changed = true;
1239 let serialized = serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string());
1240 target.insert(key.clone(), Value::String(serialized));
1241 }
1242 Value::Object(nested) => {
1243 *changed = true;
1244 flatten_environment_entries(nested, target, changed);
1245 }
1246 }
1247 }
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252 use super::{
1253 command_exists, detect_xbp_config_heal_opportunities, first_available_command,
1254 git_remote_url_from_metadata, maybe_auto_convert_legacy_xbp_json_to_yaml,
1255 parse_config_with_auto_heal, parse_github_repo_from_remote_url, preferred_pip_command,
1256 preferred_python_command, redact_remote_url_credentials, resolve_xbp_project_for_path,
1257 write_json_config_from_any_xbp_config, KnownXbpProject,
1258 };
1259 use crate::strategies::XbpConfig;
1260 use std::fs;
1261 use std::path::PathBuf;
1262 use std::sync::{Mutex, OnceLock};
1263
1264 fn path_lock() -> &'static Mutex<()> {
1265 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1266 LOCK.get_or_init(|| Mutex::new(()))
1267 }
1268
1269 fn make_temp_path(name: &str) -> PathBuf {
1270 let mut path = std::env::temp_dir();
1271 path.push(format!("xbp-test-{}-{}", name, std::process::id()));
1272 path
1273 }
1274
1275 fn with_path<F>(entries: &[PathBuf], test: F)
1276 where
1277 F: FnOnce(),
1278 {
1279 let _guard = path_lock().lock().expect("path lock should be available");
1280 let original = std::env::var_os("PATH");
1281 let joined = std::env::join_paths(entries).expect("PATH entries should join");
1282 std::env::set_var("PATH", joined);
1283 test();
1284 match original {
1285 Some(path) => std::env::set_var("PATH", path),
1286 None => std::env::remove_var("PATH"),
1287 }
1288 }
1289
1290 #[test]
1291 fn parses_yaml_with_leading_utf8_bom_and_rewrites_clean_file() {
1292 let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n";
1294
1295 let (config, healed_content) =
1296 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("BOM yaml should parse");
1297
1298 assert_eq!(config.project_name, "demo");
1299 assert_eq!(config.port, 3000);
1300 let healed = healed_content.expect("BOM should trigger autonomous rewrite");
1301 assert!(
1302 !healed.starts_with('\u{feff}'),
1303 "healed yaml must not retain BOM"
1304 );
1305 assert!(healed.contains("project_name: demo"));
1306 }
1307
1308 #[test]
1309 fn drops_duplicate_top_level_openapi_null_and_keeps_first_block() {
1310 let yaml = r#"
1311project_name: athena
1312version: 1.0.0
1313port: 4052
1314build_dir: ./
1315openapi:
1316 enabled: true
1317 auto_detect_services: false
1318services:
1319 - name: athena
1320 target: rust
1321 branch: main
1322 port: 4052
1323 openapi: null
1324openapi: null
1325workers:
1326 - name: athena-auth
1327 root: services/athena-auth
1328"#;
1329
1330 let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
1331 .expect("duplicate openapi key should be healed");
1332 assert_eq!(config.project_name, "athena");
1333 assert_eq!(
1334 config.openapi.as_ref().and_then(|o| o.enabled),
1335 Some(true)
1336 );
1337 assert!(config.workers.is_some());
1338 let healed = healed_content.expect("duplicate key should trigger rewrite");
1339 let top_level_openapi_lines = healed
1340 .lines()
1341 .filter(|line| line.starts_with("openapi:"))
1342 .count();
1343 assert_eq!(
1344 top_level_openapi_lines, 1,
1345 "healed yaml should keep a single top-level openapi block: {healed}"
1346 );
1347 assert!(
1348 !healed.contains("\nopenapi: null\n") && !healed.ends_with("openapi: null"),
1349 "stray top-level openapi: null must be dropped: {healed}"
1350 );
1351 }
1352
1353 #[test]
1354 fn merges_multi_document_yaml_and_rewrites_single_document() {
1355 let yaml = r#"
1356project_name: demo
1357port: 3000
1358build_dir: $HOME/demo
1359---
1360environment:
1361 LOG_LEVEL: info
1362---
1363services:
1364 - name: api
1365 target: rust
1366 branch: main
1367 port: 3001
1368"#;
1369
1370 let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
1371 .expect("multi-document yaml should merge");
1372
1373 assert_eq!(config.project_name, "demo");
1374 assert_eq!(config.port, 3000);
1375 let environment = config.environment.expect("merged environment should exist");
1376 assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
1377 let services = config.services.expect("merged services should exist");
1378 assert_eq!(services.len(), 1);
1379 assert_eq!(services[0].name, "api");
1380
1381 let healed = healed_content.expect("multi-doc should trigger rewrite");
1382 assert!(
1383 !healed.lines().any(|line| line.trim() == "---"),
1384 "healed yaml should be a single document"
1385 );
1386 assert!(healed.contains("project_name: demo"));
1387 assert!(healed.contains("LOG_LEVEL"));
1388 assert!(healed.contains("name: api"));
1389 }
1390
1391 #[test]
1392 fn parses_bom_prefixed_multi_document_yaml() {
1393 let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: ./\n---\nversion: 1.2.3\n";
1394
1395 let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
1396 .expect("BOM + multi-doc yaml should parse");
1397
1398 assert_eq!(config.project_name, "demo");
1399 assert_eq!(config.version, "1.2.3");
1400 assert!(healed_content.is_some());
1401 }
1402
1403 #[test]
1404 fn ensure_xbp_yaml_schema_directive_is_idempotent() {
1405 let bare = "project_name: demo\nport: 3000\nbuild_dir: .\n";
1406 let once = super::ensure_xbp_yaml_schema_directive(bare);
1407 assert!(once.starts_with("# yaml-language-server: $schema="));
1408 assert!(once.contains("project_name: demo"));
1409 let twice = super::ensure_xbp_yaml_schema_directive(&once);
1410 assert_eq!(
1411 once.matches("yaml-language-server").count(),
1412 1,
1413 "should not duplicate schema directive"
1414 );
1415 assert_eq!(twice, once);
1416
1417 let with_doc = "---\nproject_name: demo\n";
1418 let headed = super::ensure_xbp_yaml_schema_directive(with_doc);
1419 assert!(headed.starts_with("---\n# yaml-language-server:"));
1420 }
1421
1422 #[test]
1423 fn detect_heal_opportunities_reports_bom_and_multidoc() {
1424 let yaml = "\u{feff}project_name: demo\n---\nsystemd: athena.service\n";
1425 let fixes = detect_xbp_config_heal_opportunities(yaml);
1426 assert!(
1427 fixes.iter().any(|fix| fix.contains("UTF-8 BOM")),
1428 "expected BOM fix, got {fixes:?}"
1429 );
1430 assert!(
1431 fixes.iter().any(|fix| fix.contains("multi-document")),
1432 "expected multi-doc fix, got {fixes:?}"
1433 );
1434 assert!(
1435 fixes.iter().any(|fix| fix.contains("systemd")),
1436 "expected systemd fix, got {fixes:?}"
1437 );
1438 }
1439
1440 #[test]
1441 fn heal_config_file_strips_bom_on_disk() {
1442 let project_root = make_temp_path("heal-bom-yaml");
1443 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1444 let yaml_path = project_root.join(".xbp").join("xbp.yaml");
1445 fs::write(
1446 &yaml_path,
1447 "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
1448 )
1449 .expect("yaml should be written with BOM");
1450
1451 let healed = super::heal_config_file(&yaml_path, "yaml").expect("heal should succeed");
1452 assert!(healed, "BOM config should be rewritten");
1453
1454 let rewritten = fs::read_to_string(&yaml_path).expect("healed yaml should be readable");
1455 assert!(
1456 !rewritten.starts_with('\u{feff}'),
1457 "rewritten file must not start with BOM"
1458 );
1459 assert!(rewritten.contains("project_name: demo"));
1460
1461 let (config, second_heal) =
1463 parse_config_with_auto_heal::<XbpConfig>(&rewritten, "yaml").expect("reparse");
1464 assert_eq!(config.project_name, "demo");
1465 assert!(second_heal.is_none());
1468
1469 fs::remove_dir_all(project_root).expect("temp project should be removed");
1470 }
1471
1472 #[test]
1473 fn heals_nested_yaml_environment_blocks() {
1474 let yaml = r#"
1475project_name: demo
1476port: 3000
1477build_dir: $HOME/demo
1478environment:
1479 production:
1480 DATABASE_URL: postgres://localhost/demo
1481 LOG_LEVEL: info
1482services:
1483 - name: api
1484 target: rust
1485 branch: main
1486 port: 3001
1487 environment:
1488 production:
1489 SERVICE_TOKEN: abc123
1490"#;
1491
1492 let (config, healed_content) =
1493 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1494
1495 let environment = config.environment.expect("top-level env should exist");
1496 assert_eq!(
1497 environment.get("DATABASE_URL"),
1498 Some(&"postgres://localhost/demo".to_string())
1499 );
1500 assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
1501
1502 let service_environment = config.services.expect("services should exist")[0]
1503 .environment
1504 .clone()
1505 .expect("service env should exist");
1506 assert_eq!(
1507 service_environment.get("SERVICE_TOKEN"),
1508 Some(&"abc123".to_string())
1509 );
1510 assert!(healed_content.is_some());
1511 }
1512
1513 #[test]
1514 fn detect_xbp_config_heal_opportunities_finds_systemd_shorthand() {
1515 let yaml = r#"
1516services:
1517 - name: api
1518 systemd: athena.service
1519 systemd_service_name: athena
1520"#;
1521 let fixes = detect_xbp_config_heal_opportunities(yaml);
1522 assert_eq!(fixes.len(), 1);
1523 assert!(fixes[0].contains("athena.service"));
1524 }
1525
1526 #[test]
1527 fn heals_systemd_string_shorthand_in_yaml() {
1528 let yaml = r#"
1529project_name: athena
1530port: 3000
1531build_dir: $HOME/athena
1532systemd: athena.service
1533services:
1534 - name: api
1535 target: rust
1536 branch: main
1537 port: 3001
1538 systemd: athena-api.service
1539"#;
1540
1541 let (config, healed_content) =
1542 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1543
1544 assert_eq!(
1545 config.systemd_service_name.as_deref(),
1546 Some("athena.service")
1547 );
1548 assert!(config.systemd.is_none());
1549
1550 let service = &config.services.expect("services should exist")[0];
1551 assert_eq!(
1552 service.systemd_service_name.as_deref(),
1553 Some("athena-api.service")
1554 );
1555 assert!(service.systemd.is_none());
1556 assert!(healed_content.is_some());
1557 assert!(!healed_content
1558 .expect("healed content should exist")
1559 .contains("systemd: athena"));
1560 }
1561
1562 #[test]
1563 fn heals_systemd_string_shorthand_without_overwriting_existing_service_name() {
1564 let yaml = r#"
1565project_name: athena
1566port: 4052
1567build_dir: $HOME/athena
1568services:
1569 - name: athena-gateway
1570 target: rust
1571 branch: main
1572 port: 4052
1573 systemd: athena.service
1574 systemd_service_name: athena
1575"#;
1576
1577 let (config, healed_content) =
1578 parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
1579
1580 let service = &config.services.expect("services should exist")[0];
1581 assert_eq!(service.systemd_service_name.as_deref(), Some("athena"));
1582 assert!(service.systemd.is_none());
1583 assert!(healed_content.is_some());
1584 }
1585
1586 #[test]
1587 fn heals_non_string_environment_values_in_json() {
1588 let json = r#"{
1589 "project_name": "demo",
1590 "port": 3000,
1591 "build_dir": "$HOME/demo",
1592 "environment": {
1593 "PORT": 3000,
1594 "DEBUG": true,
1595 "EMPTY": null
1596 }
1597}"#;
1598
1599 let (config, healed_content) =
1600 parse_config_with_auto_heal::<XbpConfig>(json, "json").expect("config should heal");
1601
1602 let environment = config.environment.expect("top-level env should exist");
1603 assert_eq!(environment.get("PORT"), Some(&"3000".to_string()));
1604 assert_eq!(environment.get("DEBUG"), Some(&"true".to_string()));
1605 assert_eq!(environment.get("EMPTY"), Some(&String::new()));
1606 assert!(healed_content.is_some());
1607 }
1608
1609 #[test]
1610 fn command_helpers_respect_path_order() {
1611 let bin_dir = make_temp_path("bin");
1612 fs::create_dir_all(&bin_dir).expect("temp dir should be created");
1613 fs::write(bin_dir.join("python"), b"").expect("python file should be created");
1614 fs::write(bin_dir.join("pip"), b"").expect("pip file should be created");
1615
1616 with_path(std::slice::from_ref(&bin_dir), || {
1617 assert!(command_exists("python"));
1618 assert_eq!(
1619 first_available_command(&["python3", "python"]),
1620 Some("python".to_string())
1621 );
1622 assert_eq!(preferred_python_command(), "python".to_string());
1623 assert_eq!(preferred_pip_command(), "pip".to_string());
1624 });
1625
1626 fs::remove_dir_all(&bin_dir).expect("temp dir should be removed");
1627 }
1628
1629 #[test]
1630 fn resolves_xbp_project_for_nested_paths() {
1631 let project_root = make_temp_path("ownership");
1632 let service_dir = project_root.join("services").join("api");
1633 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1634 fs::create_dir_all(&service_dir).expect("service dir should be created");
1635 fs::write(
1636 project_root.join(".xbp").join("xbp.json"),
1637 br#"{"project_name":"demo"}"#,
1638 )
1639 .expect("xbp config should be written");
1640
1641 let known_projects = vec![KnownXbpProject {
1642 root: project_root.clone(),
1643 name: "demo".to_string(),
1644 }];
1645
1646 assert_eq!(
1647 resolve_xbp_project_for_path(&service_dir, &known_projects),
1648 Some("demo".to_string())
1649 );
1650
1651 fs::remove_dir_all(&project_root).expect("temp project should be removed");
1652 }
1653
1654 #[test]
1655 fn ignores_non_xbp_paths_for_project_resolution() {
1656 let project_root = make_temp_path("ownership-miss");
1657 let other_dir = make_temp_path("ownership-other");
1658 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1659 fs::create_dir_all(&other_dir).expect("other dir should be created");
1660 fs::write(
1661 project_root.join(".xbp").join("xbp.json"),
1662 br#"{"project_name":"demo"}"#,
1663 )
1664 .expect("xbp config should be written");
1665
1666 let known_projects = vec![KnownXbpProject {
1667 root: project_root.clone(),
1668 name: "demo".to_string(),
1669 }];
1670
1671 assert_eq!(
1672 resolve_xbp_project_for_path(&other_dir, &known_projects),
1673 None
1674 );
1675
1676 fs::remove_dir_all(&project_root).expect("temp project should be removed");
1677 fs::remove_dir_all(&other_dir).expect("temp other dir should be removed");
1678 }
1679
1680 #[test]
1681 fn auto_converts_legacy_json_to_yaml() {
1682 let project_root = make_temp_path("json-to-yaml");
1683 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1684 let json_path = project_root.join(".xbp").join("xbp.json");
1685 fs::write(
1686 &json_path,
1687 br#"{"project_name":"demo","port":3000,"build_dir":"$HOME/demo"}"#,
1688 )
1689 .expect("json should be written");
1690
1691 let output = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &json_path)
1692 .expect("conversion should succeed")
1693 .expect("yaml path should be returned");
1694 assert!(output.exists(), "converted yaml should exist");
1695
1696 let yaml = fs::read_to_string(output).expect("yaml should be readable");
1697 assert!(yaml.contains("project_name: demo"));
1698
1699 fs::remove_dir_all(project_root).expect("temp project should be removed");
1700 }
1701
1702 #[test]
1703 fn normalizes_environment_quotes_in_yaml_output() {
1704 let yaml = super::normalize_xbp_yaml_environment_quotes(
1705 r#"project_name: demo
1706port: 3000
1707build_dir: ./
1708environment:
1709 API_PATH: /api/auth
1710 WITH_DOUBLE: 'look "here"'
1711 WITH_SINGLE: "can't fail"
1712services:
1713 - name: api
1714 target: rust
1715 branch: main
1716 port: 3001
1717 environment:
1718 CALLBACK_PATH: /api/auth/callback
1719 CALLBACK_TITLE: "the 'quoted' callback"
1720"#,
1721 );
1722
1723 assert!(yaml.contains("API_PATH: \"/api/auth\""));
1724 assert!(yaml.contains("WITH_DOUBLE: 'look \"here\"'"));
1725 assert!(yaml.contains("WITH_SINGLE: \"can't fail\""));
1726 assert!(yaml.contains("CALLBACK_PATH: \"/api/auth/callback\""));
1727 assert!(yaml.contains("CALLBACK_TITLE: \"the 'quoted' callback\""));
1728 }
1729
1730 #[test]
1731 fn writes_json_from_yaml_config() {
1732 let project_root = make_temp_path("yaml-to-json");
1733 fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
1734 let yaml_path = project_root.join(".xbp").join("xbp.yaml");
1735 let json_out = project_root.join("xbp.json");
1736 fs::write(
1737 &yaml_path,
1738 "project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
1739 )
1740 .expect("yaml should be written");
1741
1742 write_json_config_from_any_xbp_config(&yaml_path, &json_out)
1743 .expect("json should be generated from yaml");
1744
1745 let json = fs::read_to_string(json_out).expect("json should be readable");
1746 assert!(json.contains("\"project_name\": \"demo\""));
1747
1748 fs::remove_dir_all(project_root).expect("temp project should be removed");
1749 }
1750
1751 #[test]
1752 fn parses_github_repo_from_supported_remote_urls() {
1753 assert_eq!(
1754 parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
1755 Some(("xylex-group".to_string(), "xbp".to_string()))
1756 );
1757 assert_eq!(
1758 parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
1759 Some(("xylex-group".to_string(), "xbp".to_string()))
1760 );
1761 assert_eq!(
1762 parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
1763 Some(("xylex-group".to_string(), "xbp".to_string()))
1764 );
1765 assert_eq!(
1766 parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
1767 None
1768 );
1769 assert_eq!(
1770 parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp/extra.git"),
1771 None
1772 );
1773 }
1774
1775 #[test]
1776 fn redacts_credentials_in_remote_urls() {
1777 let redacted = redact_remote_url_credentials("https://token@example.com/xylex-group/xbp");
1778 assert!(redacted.contains("REDACTED"));
1779 assert!(!redacted.contains("token@example.com"));
1780 }
1781
1782 #[test]
1783 fn reads_origin_remote_url_from_git_config_directory() {
1784 let project_root = make_temp_path("git-config-dir");
1785 let git_dir = project_root.join(".git");
1786 fs::create_dir_all(&git_dir).expect("git dir should be created");
1787 fs::write(
1788 git_dir.join("config"),
1789 "[remote \"origin\"]\n\turl = https://github.com/xylex-group/xbp.git\n",
1790 )
1791 .expect("git config should be written");
1792
1793 let remote = git_remote_url_from_metadata(&project_root, "origin")
1794 .expect("git metadata should parse")
1795 .expect("origin remote should exist");
1796 assert_eq!(remote, "https://github.com/xylex-group/xbp.git");
1797
1798 fs::remove_dir_all(project_root).expect("temp project should be removed");
1799 }
1800
1801 #[test]
1802 fn reads_origin_remote_url_from_gitdir_pointer() {
1803 let project_root = make_temp_path("git-config-file");
1804 let nested_git_dir = project_root.join(".git-real");
1805 fs::create_dir_all(&nested_git_dir).expect("gitdir target should be created");
1806 fs::write(project_root.join(".git"), "gitdir: .git-real\n")
1807 .expect("gitdir pointer should be written");
1808 fs::write(
1809 nested_git_dir.join("config"),
1810 "[remote \"origin\"]\n\turl = git@github.com:xylex-group/xbp.git\n",
1811 )
1812 .expect("git config should be written");
1813
1814 let remote = git_remote_url_from_metadata(&project_root, "origin")
1815 .expect("git metadata should parse")
1816 .expect("origin remote should exist");
1817 assert_eq!(remote, "git@github.com:xylex-group/xbp.git");
1818
1819 fs::remove_dir_all(project_root).expect("temp project should be removed");
1820 }
1821
1822 #[test]
1823 fn reads_origin_remote_url_from_ancestor_git_dir() {
1824 let repo_root = make_temp_path("git-ancestor-repo");
1826 let package_root = repo_root.join("packages").join("athena-auth-ui");
1827 fs::create_dir_all(&package_root).expect("package dir should be created");
1828 let git_dir = repo_root.join(".git");
1829 fs::create_dir_all(&git_dir).expect("git dir should be created");
1830 fs::write(
1831 git_dir.join("config"),
1832 "[remote \"origin\"]\n\turl = https://github.com/xylex-group/athena.git\n",
1833 )
1834 .expect("git config should be written");
1835
1836 let remote = git_remote_url_from_metadata(&package_root, "origin")
1837 .expect("git metadata should parse")
1838 .expect("origin remote should exist via ancestor .git");
1839 assert_eq!(remote, "https://github.com/xylex-group/athena.git");
1840
1841 fs::remove_dir_all(repo_root).expect("temp project should be removed");
1842 }
1843}