pub mod cargo_manifest;
pub mod env_files;
pub mod node_toolchain;
pub mod pnpm_package_json;
pub mod process_monitor_json;
pub mod project_paths;
pub mod tray_runtime;
pub mod version;
pub mod xbp_ignore;
pub use cargo_manifest::{
resolve_cargo_package_version, resolve_cargo_package_version_required,
write_cargo_package_version,
};
pub use env_files::{
canonicalize_env_value, escape_env_value_double_quoted, first_lookup_value,
format_env_assignment, format_env_file_content, format_env_value_double_quoted, load_env_lookup,
normalize_env_key, normalize_env_value, parse_env_content, parse_env_file,
preferred_local_secret_env_path, resolve_env_placeholders, strip_utf8_bom, to_env_references,
upsert_env_key, CLOUDFLARE_ACCOUNT_ID_ENV_KEYS,
};
pub use node_toolchain::{
find_node_toolchain_root, is_node_toolchain_command, node_toolchain_wrapper_path,
};
pub use process_monitor_json::{
fix_cursor_process_monitor_json, fix_cursor_process_monitor_json_file,
CursorProcessMonitorJsonFix,
};
pub use pnpm_package_json::{migrate_package_json_pnpm_settings, PnpmSettingsMigration};
pub use project_paths::{
canonicalize_for_subprocess, collapse_project_path, resolve_project_dir, resolve_project_path,
resolve_service_root,
};
pub use version::{fetch_version, increment_version};
pub use xbp_ignore::{
default_global_worktree_ignore_content, default_xbp_ignore_path,
load_global_worktree_ignore_from_path, load_project_xbp_ignore, sync_global_worktree_ignore_file,
xbp_ignore_file_candidates, XbpIgnoreSet, DEFAULT_DISCOVERY_SKIP_DIRS,
DEFAULT_GLOBAL_WORKTREE_IGNORE_PATTERNS, DEFAULT_NOISE_DIR_NAMES,
GLOBAL_WORKTREE_IGNORE_FILENAME,
};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use sysinfo::{Pid, System};
use crate::profile::find_all_xbp_projects;
#[derive(Debug, Clone)]
pub struct FoundXbpConfig {
pub project_root: PathBuf,
pub config_path: PathBuf,
pub kind: &'static str,
pub location: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KnownXbpProject {
pub root: PathBuf,
pub name: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ListeningPortOwnership {
pub pids: Vec<u32>,
pub xbp_projects: Vec<String>,
}
pub const PROJECT_CONFIG_FILENAMES: &[&str] = &[
"xbp.toml",
"xbp.jsonc",
"xbp.yaml",
"xbp.yml",
"xbp.json",
];
fn project_config_kind_for_filename(name: &str) -> &'static str {
match name {
"xbp.toml" => "toml",
"xbp.jsonc" => "jsonc",
"xbp.yaml" | "xbp.yml" => "yaml",
"xbp.json" => "json",
_ => "yaml",
}
}
pub const PROJECT_CONFIG_HINT: &str =
".xbp/xbp.{toml,jsonc,yaml,yml,json} or xbp.{toml,jsonc,yaml,yml,json}";
pub const DEFAULT_PROJECT_CONFIG_KIND: &str = "toml";
pub const DEFAULT_PROJECT_CONFIG_RELATIVE: &str = ".xbp/xbp.toml";
pub fn default_project_yaml_config_path(project_root: &Path) -> PathBuf {
default_project_config_path(project_root, DEFAULT_PROJECT_CONFIG_KIND)
}
pub fn project_config_display_path(project_root: &Path) -> String {
find_existing_project_config(project_root)
.map(|path| collapse_project_path(project_root, &path.to_string_lossy()))
.filter(|rel| !rel.is_empty())
.unwrap_or_else(|| DEFAULT_PROJECT_CONFIG_RELATIVE.to_string())
}
pub fn default_project_config_path(project_root: &Path, kind: &str) -> PathBuf {
let filename = match kind {
"yaml" | "yml" => "xbp.yaml",
"json" => "xbp.json",
"jsonc" => "xbp.jsonc",
"toml" => "xbp.toml",
_ => "xbp.toml",
};
project_root.join(".xbp").join(filename)
}
pub fn project_config_candidates(project_root: &Path) -> Vec<(PathBuf, &'static str)> {
let mut out = Vec::with_capacity(PROJECT_CONFIG_FILENAMES.len() * 2);
for name in PROJECT_CONFIG_FILENAMES {
let kind = project_config_kind_for_filename(name);
out.push((project_root.join(".xbp").join(name), kind));
}
for name in PROJECT_CONFIG_FILENAMES {
let kind = project_config_kind_for_filename(name);
out.push((project_root.join(name), kind));
}
out
}
pub fn find_existing_project_config(project_root: &Path) -> Option<PathBuf> {
project_config_candidates(project_root)
.into_iter()
.map(|(path, _)| path)
.find(|path| path.exists())
}
pub fn find_existing_yaml_xbp_config(project_root: &Path) -> Option<PathBuf> {
let candidates = [
project_root.join(".xbp").join("xbp.yaml"),
project_root.join(".xbp").join("xbp.yml"),
project_root.join("xbp.yaml"),
project_root.join("xbp.yml"),
];
candidates.into_iter().find(|candidate| candidate.exists())
}
pub fn resolve_project_config_write_path(
project_root: &Path,
existing: Option<&FoundXbpConfig>,
) -> PathBuf {
if let Some(found) = existing {
return found.config_path.clone();
}
find_existing_project_config(project_root)
.unwrap_or_else(|| default_project_config_path(project_root, DEFAULT_PROJECT_CONFIG_KIND))
}
pub fn serialize_xbp_config<T: Serialize>(value: &T, kind: &str) -> Result<String, String> {
let mut json = serde_json::to_value(value)
.map_err(|e| format!("Failed to prepare project config: {e}"))?;
let _ = ensure_named_entry_key_order(&mut json);
render_xbp_config_value(&json, kind)
}
pub fn write_xbp_project_config_at_path<T: Serialize>(
config_path: &Path,
value: &T,
) -> Result<(), String> {
let kind = config_kind_from_path(config_path)?;
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
format!(
"Failed to create config directory {}: {e}",
parent.display()
)
})?;
}
let rendered = match kind {
"yaml" | "yml" => serialize_xbp_yaml_for_path(value, Some(config_path))?,
other => serialize_xbp_config(value, other)?,
};
fs::write(config_path, rendered).map_err(|e| {
format!(
"Failed to write project config {}: {e}",
config_path.display()
)
})
}
pub fn load_xbp_project_config_value(
config_path: &Path,
) -> Result<(Value, &'static str, Option<String>), String> {
let kind = config_kind_from_path(config_path)?;
let content = fs::read_to_string(config_path).map_err(|e| {
format!(
"Failed to read project config {}: {e}",
config_path.display()
)
})?;
let (value, healed) = parse_config_with_auto_heal::<Value>(&content, kind)
.map_err(|e| format!("Failed to parse project config {}: {e}", config_path.display()))?;
Ok((value, kind, healed))
}
pub fn maybe_auto_convert_legacy_xbp_json_to_yaml(
project_root: &Path,
config_path: &Path,
) -> Result<Option<PathBuf>, String> {
if let Some(existing) = find_existing_project_config(project_root) {
return Ok(Some(existing));
}
if config_path.exists() {
return Ok(Some(config_path.to_path_buf()));
}
Ok(None)
}
pub const XBP_PROJECT_YAML_SCHEMA_URL: &str =
"https://raw.githubusercontent.com/xylex-group/xbp-mirrors/refs/heads/main/xbp.project.schema.json";
pub const XBP_PROJECT_YAML_SCHEMA_DIRECTIVE: &str =
"# yaml-language-server: $schema=https://raw.githubusercontent.com/xylex-group/xbp-mirrors/refs/heads/main/xbp.project.schema.json\n";
pub fn serialize_xbp_yaml<T: Serialize>(value: &T) -> Result<String, String> {
serialize_xbp_yaml_for_path(value, None)
}
pub fn serialize_xbp_yaml_for_path<T: Serialize>(
value: &T,
config_path: Option<&Path>,
) -> Result<String, String> {
let mut json = serde_json::to_value(value)
.map_err(|e| format!("Failed to prepare YAML config: {}", e))?;
let _ = ensure_named_entry_key_order(&mut json);
let yaml = serde_yaml::to_string(&json)
.map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
let body = normalize_xbp_yaml_environment_quotes(&yaml);
let directive = config_path
.map(xbp_project_schema_directive_for_path)
.unwrap_or_else(|| XBP_PROJECT_YAML_SCHEMA_DIRECTIVE.to_string());
Ok(ensure_xbp_yaml_schema_directive_with(&body, &directive))
}
pub fn is_xbp_project_config_filename(path: &str) -> bool {
let normalized = path.replace('\\', "/");
let file = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
PROJECT_CONFIG_FILENAMES
.iter()
.any(|name| file.eq_ignore_ascii_case(name))
}
pub fn xbp_project_schema_directive_for_path(config_path: &Path) -> String {
let project_root = config_path
.parent()
.and_then(|p| {
if p.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
p.parent()
} else {
Some(p)
}
})
.map(Path::to_path_buf);
if let Some(root) = project_root {
let local_schema = root.join("schemas").join("xbp.project.schema.json");
if local_schema.is_file() {
if let Ok(rel) = pathdiff_relative(config_path.parent().unwrap_or(config_path), &local_schema)
{
let rel = rel.replace('\\', "/");
return format!("# yaml-language-server: $schema={rel}\n");
}
if config_path
.parent()
.and_then(|p| p.file_name())
== Some(std::ffi::OsStr::new(".xbp"))
{
return "# yaml-language-server: $schema=../schemas/xbp.project.schema.json\n"
.to_string();
}
return "# yaml-language-server: $schema=./schemas/xbp.project.schema.json\n"
.to_string();
}
}
XBP_PROJECT_YAML_SCHEMA_DIRECTIVE.to_string()
}
fn pathdiff_relative(from_dir: &Path, to_file: &Path) -> Result<String, ()> {
let from = fs::canonicalize(from_dir).map_err(|_| ())?;
let to = fs::canonicalize(to_file).map_err(|_| ())?;
let mut from_components: Vec<_> = from.components().collect();
let mut to_components: Vec<_> = to.components().collect();
while !from_components.is_empty()
&& !to_components.is_empty()
&& from_components[0] == to_components[0]
{
from_components.remove(0);
to_components.remove(0);
}
let mut parts: Vec<String> = from_components.iter().map(|_| "..".to_string()).collect();
for c in to_components {
parts.push(c.as_os_str().to_string_lossy().into_owned());
}
if parts.is_empty() {
return Err(());
}
Ok(parts.join("/"))
}
pub fn ensure_xbp_yaml_schema_directive(yaml: &str) -> String {
ensure_xbp_yaml_schema_directive_with(yaml, XBP_PROJECT_YAML_SCHEMA_DIRECTIVE)
}
pub fn ensure_xbp_yaml_schema_directive_with(yaml: &str, directive: &str) -> String {
let directive = if directive.ends_with('\n') {
directive.to_string()
} else {
format!("{directive}\n")
};
let trimmed = yaml.trim_start_matches('\u{feff}');
let lines: Vec<&str> = trimmed.lines().collect();
let mut body_start = 0usize;
if lines.first().is_some_and(|l| l.trim() == "---") {
body_start = 1;
}
while body_start < lines.len() {
let line = lines[body_start].trim();
if line.is_empty() {
body_start += 1;
continue;
}
if line.contains("yaml-language-server") && line.contains("$schema") {
body_start += 1;
continue;
}
break;
}
let body = lines[body_start..].join("\n");
let body = if trimmed.ends_with('\n') && !body.ends_with('\n') {
format!("{body}\n")
} else {
body
};
if lines.first().is_some_and(|l| l.trim() == "---") {
format!("---\n{directive}{body}")
} else {
format!("{directive}{body}")
}
}
fn normalize_xbp_yaml_environment_quotes(yaml: &str) -> String {
let mut rendered = String::with_capacity(yaml.len());
let mut environment_indent: Option<usize> = None;
for raw_line in yaml.split_inclusive('\n') {
let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
let newline = if raw_line.ends_with('\n') { "\n" } else { "" };
let indent = line
.chars()
.take_while(|character| *character == ' ')
.count();
if let Some(active_indent) = environment_indent {
if !line.trim().is_empty() && indent <= active_indent {
environment_indent = None;
}
}
if line.trim() == "environment:" {
environment_indent = Some(indent);
rendered.push_str(line);
rendered.push_str(newline);
continue;
}
if let Some(active_indent) = environment_indent {
if !line.trim().is_empty() && indent > active_indent {
if let Some(rewritten) = rewrite_environment_scalar_line(line) {
rendered.push_str(&rewritten);
rendered.push_str(newline);
continue;
}
}
}
rendered.push_str(line);
rendered.push_str(newline);
}
rendered
}
fn rewrite_environment_scalar_line(line: &str) -> Option<String> {
let (prefix, raw_value) = line.split_once(':')?;
let value_fragment = raw_value.trim_start();
if value_fragment.is_empty() {
return Some(format!("{prefix}: \"\""));
}
let parsed =
serde_yaml::from_str::<serde_yaml::Value>(&format!("value: {value_fragment}\n")).ok()?;
let value = match parsed {
serde_yaml::Value::Mapping(mut mapping) => {
mapping.remove(serde_yaml::Value::String("value".to_string()))?
}
_ => return None,
};
let text = match value {
serde_yaml::Value::String(text) => text,
serde_yaml::Value::Bool(value) => value.to_string(),
serde_yaml::Value::Number(value) => value.to_string(),
serde_yaml::Value::Null => String::new(),
_ => return None,
};
Some(format!(
"{prefix}: {}",
render_yaml_environment_scalar(&text)
))
}
fn render_yaml_environment_scalar(value: &str) -> String {
if value.is_empty() {
return "\"\"".to_string();
}
let contains_control = value
.chars()
.any(|character| matches!(character, '\n' | '\r' | '\t') || character.is_control());
if !contains_control && value.contains('"') {
let escaped = value.replace('\'', "''");
format!("'{escaped}'")
} else {
format!("\"{}\"", escape_yaml_double_quoted(value))
}
}
fn escape_yaml_double_quoted(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
match character {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
character if character.is_control() => {
use std::fmt::Write as _;
let _ = write!(&mut escaped, "\\u{:04x}", character as u32);
}
character => escaped.push(character),
}
}
escaped
}
pub fn write_json_config_from_any_xbp_config(
config_path: &Path,
output_json_path: &Path,
) -> Result<(), String> {
let content = fs::read_to_string(config_path)
.map_err(|e| format!("Failed to read config {}: {}", config_path.display(), e))?;
let kind = config_kind_from_path(config_path)?;
let (value, _) = parse_config_with_auto_heal::<Value>(&content, kind)
.map_err(|e| format!("Failed to parse config: {}", e))?;
if let Some(parent) = output_json_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
format!(
"Failed to create config directory {}: {}",
parent.display(),
e
)
})?;
}
let rendered = serde_json::to_string_pretty(&value)
.map_err(|e| format!("Failed to serialize JSON config: {}", e))?;
fs::write(output_json_path, rendered).map_err(|e| {
format!(
"Failed to write JSON config {}: {}",
output_json_path.display(),
e
)
})?;
Ok(())
}
pub fn find_xbp_config_upwards(start_dir: &Path) -> Option<FoundXbpConfig> {
for dir in start_dir.ancestors() {
for (path, kind) in project_config_candidates(dir) {
if !path.exists() {
continue;
}
let project_root = path
.parent()
.and_then(|p| {
if p.file_name() == Some(std::ffi::OsStr::new(".xbp")) {
p.parent().map(|pp| pp.to_path_buf())
} else {
Some(p.to_path_buf())
}
})
.unwrap_or_else(|| dir.to_path_buf());
let location = path
.strip_prefix(&project_root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|| path.to_string_lossy().replace('\\', "/"));
return Some(FoundXbpConfig {
project_root,
config_path: path,
kind,
location,
});
}
}
None
}
pub fn collect_known_xbp_projects() -> Vec<KnownXbpProject> {
let mut projects = Vec::new();
let mut seen_roots = HashSet::new();
for project in find_all_xbp_projects() {
let root = canonicalize_or_fallback(&project.path);
if seen_roots.insert(root.clone()) {
projects.push(KnownXbpProject {
root,
name: project.name,
});
}
}
if let Ok(current_dir) = std::env::current_dir() {
if let Some(found) = find_xbp_config_upwards(¤t_dir) {
let root = canonicalize_or_fallback(&found.project_root);
if seen_roots.insert(root.clone()) {
let name = root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("current")
.to_string();
projects.push(KnownXbpProject { root, name });
}
}
}
projects.sort_by(|left, right| {
right
.root
.components()
.count()
.cmp(&left.root.components().count())
.then_with(|| left.name.cmp(&right.name))
});
projects
}
pub fn resolve_xbp_project_for_path(
candidate: &Path,
known_projects: &[KnownXbpProject],
) -> Option<String> {
if candidate.as_os_str().is_empty() {
return None;
}
if let Some(found) = find_xbp_config_upwards(candidate) {
let found_root = canonicalize_or_fallback(&found.project_root);
if let Some(project) = known_projects
.iter()
.find(|project| canonicalize_or_fallback(&project.root) == found_root)
{
return Some(project.name.clone());
}
if known_projects.is_empty() {
return found_root
.file_name()
.and_then(|value| value.to_str())
.map(|value| value.to_string());
}
}
let candidate_path = canonicalize_or_fallback(candidate);
known_projects
.iter()
.find(|project| candidate_path.starts_with(canonicalize_or_fallback(&project.root)))
.map(|project| project.name.clone())
}
pub fn collect_listening_port_ownership() -> Result<BTreeMap<u16, ListeningPortOwnership>, String> {
use netstat2::{get_sockets_info, AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo};
let af_flags = AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6;
let proto_flags = ProtocolFlags::TCP;
let sockets = get_sockets_info(af_flags, proto_flags)
.map_err(|e| format!("Failed to get sockets info: {}", e))?;
let mut system = System::new_all();
system.refresh_all();
let known_projects = collect_known_xbp_projects();
let mut pid_project_cache: HashMap<u32, Option<String>> = HashMap::new();
let mut ports: BTreeMap<u16, ListeningPortOwnership> = BTreeMap::new();
for socket in sockets {
if let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info {
let state = format!("{:?}", tcp.state);
if state != "Listen" && state != "LISTEN" {
continue;
}
let row = ports.entry(tcp.local_port).or_default();
for pid in socket.associated_pids {
row.pids.push(pid);
if let Some(project) = pid_project_cache
.entry(pid)
.or_insert_with(|| resolve_xbp_project_for_pid(pid, &system, &known_projects))
.clone()
{
row.xbp_projects.push(project);
}
}
}
}
for row in ports.values_mut() {
row.pids.sort_unstable();
row.pids.dedup();
row.xbp_projects.sort();
row.xbp_projects.dedup();
}
Ok(ports)
}
fn resolve_xbp_project_for_pid(
pid: u32,
system: &System,
known_projects: &[KnownXbpProject],
) -> Option<String> {
let process = system.process(Pid::from_u32(pid))?;
if let Some(project) = process
.cwd()
.and_then(|path| resolve_xbp_project_for_path(path, known_projects))
{
return Some(project);
}
process
.exe()
.and_then(|path| path.parent())
.and_then(|path| resolve_xbp_project_for_path(path, known_projects))
}
fn canonicalize_or_fallback(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
pub fn expand_home_in_string(input: &str) -> String {
let home = dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.to_string_lossy()
.to_string();
if input == "~" {
return home;
}
if let Some(rest) = input
.strip_prefix("~/")
.or_else(|| input.strip_prefix("~\\"))
{
return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
}
if let Some(rest) = input
.strip_prefix("$HOME/")
.or_else(|| input.strip_prefix("$HOME\\"))
{
return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
}
if let Some(rest) = input
.strip_prefix("${HOME}/")
.or_else(|| input.strip_prefix("${HOME}\\"))
{
return format!("{}{}{}", home, std::path::MAIN_SEPARATOR, rest);
}
input.to_string()
}
pub fn collapse_home_to_env(input: &str) -> String {
let home = dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.to_string_lossy()
.to_string();
if input == home {
return "$HOME".to_string();
}
if let Some(rest) = input.strip_prefix(&(home.clone() + "/")) {
return format!("$HOME/{}", rest);
}
if let Some(rest) = input.strip_prefix(&(home.clone() + "\\")) {
return format!("$HOME\\{}", rest);
}
input.to_string()
}
pub fn cargo_program() -> PathBuf {
std::env::var_os("CARGO")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("cargo"))
}
pub fn cargo_command_exists() -> bool {
let program = cargo_program();
if program.is_absolute() {
return program.is_file();
}
command_exists(program.to_string_lossy().as_ref())
}
pub fn command_exists(program: &str) -> bool {
let Some(path_var) = std::env::var_os("PATH") else {
return false;
};
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(program);
if candidate.is_file() {
return true;
}
#[cfg(windows)]
for ext in ["exe", "cmd", "bat"] {
let candidate = dir.join(format!("{}.{}", program, ext));
if candidate.is_file() {
return true;
}
}
}
false
}
pub fn git_remote_url_from_metadata(
project_root: &Path,
remote: &str,
) -> Result<Option<String>, String> {
let Some(git_dir) = resolve_git_dir(project_root)? else {
return Ok(None);
};
let config_path = git_dir.join("config");
if !config_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read git config {}: {}", config_path.display(), e))?;
Ok(parse_git_remote_url_from_config(&content, remote))
}
pub fn parse_github_repo_from_remote_url(url: &str) -> Option<(String, String)> {
let normalized: &str = url.trim();
let repo_path: String = if let Some(path) = normalized.strip_prefix("git@github.com:") {
path.to_string()
} else if let Some(path) = parse_github_https_repo_path(normalized) {
path
} else if let Some(path) = normalized.strip_prefix("ssh://git@github.com/") {
path.to_string()
} else {
return None;
};
let cleaned: &str = repo_path.trim_end_matches('/').trim_end_matches(".git");
let mut segments: std::str::Split<'_, char> = cleaned.split('/');
let owner: &str = segments.next()?.trim();
let repo: &str = segments.next()?.trim();
if owner.is_empty() || repo.is_empty() || segments.next().is_some() {
return None;
}
Some((owner.to_string(), repo.to_string()))
}
pub fn redact_remote_url_credentials(url: &str) -> String {
if !url.contains('@') || !url.contains("://") {
return url.to_string();
}
let mut parsed: reqwest::Url = match reqwest::Url::parse(url) {
Ok(value) => value,
Err(_) => return url.to_string(),
};
if parsed.password().is_some() {
let _ = parsed.set_password(Some("REDACTED"));
}
if !parsed.username().is_empty() {
let _ = parsed.set_username("REDACTED");
}
parsed.to_string()
}
fn resolve_git_dir(project_root: &Path) -> Result<Option<PathBuf>, String> {
for dir in project_root.ancestors() {
if let Some(git_dir) = resolve_git_dir_at(dir)? {
return Ok(Some(git_dir));
}
}
Ok(None)
}
fn resolve_git_dir_at(dir: &Path) -> Result<Option<PathBuf>, String> {
let dot_git = dir.join(".git");
if dot_git.is_dir() {
return Ok(Some(dot_git));
}
if !dot_git.exists() {
return Ok(None);
}
let content = fs::read_to_string(&dot_git)
.map_err(|e| format!("Failed to read git metadata {}: {}", dot_git.display(), e))?;
let git_dir = content
.lines()
.find_map(|line| line.trim().strip_prefix("gitdir:").map(str::trim))
.filter(|value| !value.is_empty())
.ok_or_else(|| format!("Failed to parse gitdir pointer from {}", dot_git.display()))?;
let git_dir_path = PathBuf::from(git_dir);
let resolved = if git_dir_path.is_absolute() {
git_dir_path
} else {
dot_git.parent().unwrap_or(dir).join(git_dir_path)
};
Ok(Some(resolved))
}
fn parse_git_remote_url_from_config(content: &str, remote: &str) -> Option<String> {
let expected_quoted = format!(r#"remote "{}""#, remote);
let expected_dotted = format!("remote.{}", remote);
let mut in_target_section = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let section = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
in_target_section = section.eq_ignore_ascii_case(&expected_quoted)
|| section.eq_ignore_ascii_case(&expected_dotted);
continue;
}
if !in_target_section {
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
if key.trim().eq_ignore_ascii_case("url") {
let value = value.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}
fn parse_github_https_repo_path(url: &str) -> Option<String> {
let parsed: reqwest::Url = reqwest::Url::parse(url).ok()?;
if !matches!(parsed.scheme(), "http" | "https") {
return None;
}
if parsed.host_str()?.eq_ignore_ascii_case("github.com") {
return Some(parsed.path().trim_start_matches('/').to_string());
}
None
}
pub fn first_available_command(candidates: &[&str]) -> Option<String> {
candidates
.iter()
.find(|candidate| command_exists(candidate))
.map(|candidate| (*candidate).to_string())
}
pub fn preferred_python_command() -> String {
first_available_command(&["python3", "python"]).unwrap_or_else(|| {
if cfg!(target_os = "windows") {
"python".to_string()
} else {
"python3".to_string()
}
})
}
pub fn preferred_pip_command() -> String {
first_available_command(&["pip3", "pip"]).unwrap_or_else(|| {
if cfg!(target_os = "windows") {
"pip".to_string()
} else {
"pip3".to_string()
}
})
}
pub fn open_with_default_handler(target: &str) -> Result<(), String> {
let mut command = if cfg!(target_os = "windows") {
let mut cmd = Command::new("cmd");
cmd.arg("/C").arg("start").arg("").arg(target);
cmd
} else if cfg!(target_os = "macos") {
let mut cmd = Command::new("open");
cmd.arg(target);
cmd
} else {
let mut cmd = Command::new("xdg-open");
cmd.arg(target);
cmd
};
command
.spawn()
.map_err(|e| format!("Failed to open '{}': {}", target, e))?;
Ok(())
}
pub fn open_path_with_editor(path: &Path) -> Result<(), String> {
if let Ok(editor) = std::env::var("EDITOR") {
let mut parts = editor.split_whitespace();
let binary = parts
.next()
.ok_or_else(|| "EDITOR is set but empty".to_string())?;
let mut command = Command::new(binary);
for part in parts {
command.arg(part);
}
command
.arg(path)
.spawn()
.map_err(|e| format!("Failed to launch editor '{}': {}", editor, e))?;
return Ok(());
}
open_with_default_handler(&path.display().to_string())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct ConfigParseNormalizeMeta {
had_bom: bool,
multi_document: bool,
duplicate_top_level_keys: bool,
duplicate_nested_keys: bool,
quoted_colon_keys: bool,
salvaged_services: bool,
}
impl ConfigParseNormalizeMeta {
fn needs_rewrite(&self) -> bool {
self.had_bom
|| self.multi_document
|| self.duplicate_top_level_keys
|| self.duplicate_nested_keys
|| self.quoted_colon_keys
|| self.salvaged_services
}
}
fn dedupe_yaml_top_level_keys(content: &str) -> (String, bool) {
let mut output = String::with_capacity(content.len());
let mut changed = false;
let mut first_document = true;
for document in split_yaml_documents(content) {
if !first_document {
if !output.ends_with('\n') && !output.is_empty() {
output.push('\n');
}
output.push_str("---\n");
}
first_document = false;
let (doc_out, doc_changed) = dedupe_yaml_document_top_level_keys(document);
changed |= doc_changed;
output.push_str(&doc_out);
}
if content.ends_with('\n') && !output.ends_with('\n') {
output.push('\n');
}
(output, changed)
}
fn split_yaml_documents(content: &str) -> Vec<&str> {
let mut documents = Vec::new();
let mut start = 0usize;
let mut offset = 0usize;
for raw_line in content.split_inclusive('\n') {
let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
let trimmed = line.trim_start();
if offset > start && (trimmed == "---" || trimmed.starts_with("--- ")) {
documents.push(&content[start..offset]);
start = offset + raw_line.len();
}
offset += raw_line.len();
}
documents.push(&content[start..]);
documents
}
#[derive(Debug, Clone)]
struct YamlTopLevelBlock {
key: Option<String>,
text: String,
}
fn dedupe_yaml_document_top_level_keys(document: &str) -> (String, bool) {
let blocks = split_yaml_top_level_blocks(document);
if blocks.is_empty() {
return (document.to_string(), false);
}
let mut leading = String::new();
let mut keyed: Vec<YamlTopLevelBlock> = Vec::new();
let mut trailing_unkeyed = String::new();
let mut seen_key = false;
for block in blocks {
match &block.key {
None if !seen_key => leading.push_str(&block.text),
None => trailing_unkeyed.push_str(&block.text),
Some(_) => {
seen_key = true;
keyed.push(block);
}
}
}
let mut order: Vec<String> = Vec::new();
let mut chosen: HashMap<String, YamlTopLevelBlock> = HashMap::new();
let mut changed = false;
for block in keyed {
let key = block.key.clone().expect("keyed block");
match chosen.get(&key) {
None => {
order.push(key.clone());
chosen.insert(key, block);
}
Some(existing) => {
changed = true;
if yaml_block_is_better(&key, &block, existing) {
chosen.insert(key, block);
}
}
}
}
if !trailing_unkeyed.is_empty() {
changed = true;
}
if !changed {
return (document.to_string(), false);
}
let mut output = leading;
for key in order {
if let Some(block) = chosen.remove(&key) {
output.push_str(&block.text);
}
}
(output, true)
}
fn split_yaml_top_level_blocks(document: &str) -> Vec<YamlTopLevelBlock> {
let mut blocks: Vec<YamlTopLevelBlock> = Vec::new();
let mut current_key: Option<String> = None;
let mut current_text = String::new();
let flush = |key: &mut Option<String>, text: &mut String, blocks: &mut Vec<YamlTopLevelBlock>| {
if text.is_empty() {
return;
}
blocks.push(YamlTopLevelBlock {
key: key.take(),
text: std::mem::take(text),
});
};
for raw_line in document.split_inclusive('\n') {
let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
let trimmed = line.trim_start();
let indent = line.len().saturating_sub(trimmed.len());
let starts_new_key = indent == 0
&& !trimmed.is_empty()
&& !trimmed.starts_with('#')
&& top_level_yaml_key(trimmed).is_some();
if starts_new_key {
flush(&mut current_key, &mut current_text, &mut blocks);
current_key = top_level_yaml_key(trimmed);
current_text.push_str(raw_line);
continue;
}
if indent == 0
&& !trimmed.is_empty()
&& !trimmed.starts_with('#')
&& trimmed.starts_with('-')
&& current_key.is_some()
{
current_text.push_str(raw_line);
continue;
}
if current_key.is_some()
|| indent > 0
|| trimmed.is_empty()
|| trimmed.starts_with('#')
{
current_text.push_str(raw_line);
continue;
}
flush(&mut current_key, &mut current_text, &mut blocks);
current_key = None;
current_text.push_str(raw_line);
}
flush(&mut current_key, &mut current_text, &mut blocks);
blocks
}
fn parse_yaml_top_level_block_value(key: &str, block_text: &str) -> Option<serde_yaml::Value> {
let wrapped = format!("{block_text}");
let value: serde_yaml::Value = serde_yaml::from_str(&wrapped).ok()?;
let mapping = value.as_mapping()?;
mapping
.get(serde_yaml::Value::String(key.to_string()))
.cloned()
.or_else(|| {
mapping.iter().find_map(|(k, v)| {
k.as_str()
.filter(|candidate| *candidate == key)
.map(|_| v.clone())
})
})
}
fn yaml_block_is_better(key: &str, candidate: &YamlTopLevelBlock, existing: &YamlTopLevelBlock) -> bool {
let candidate_value = parse_yaml_top_level_block_value(key, &candidate.text);
let existing_value = parse_yaml_top_level_block_value(key, &existing.text);
match (candidate_value, existing_value) {
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => candidate.text.len() > existing.text.len(),
(Some(cand), Some(exist)) => yaml_value_is_richer(&cand, &exist),
}
}
fn yaml_value_is_richer(candidate: &serde_yaml::Value, existing: &serde_yaml::Value) -> bool {
use serde_yaml::Value as Y;
match (candidate, existing) {
(Y::Null, _) => false,
(_, Y::Null) => true,
(Y::Sequence(c), Y::Sequence(e)) => {
if c.len() != e.len() {
c.len() > e.len()
} else {
yaml_value_complexity(candidate) > yaml_value_complexity(existing)
}
}
(Y::Mapping(c), Y::Mapping(e)) => {
let c_non_null = c.values().filter(|v| !matches!(v, Y::Null)).count();
let e_non_null = e.values().filter(|v| !matches!(v, Y::Null)).count();
if c_non_null != e_non_null {
c_non_null > e_non_null
} else {
yaml_value_complexity(candidate) >= yaml_value_complexity(existing)
}
}
(Y::Sequence(_), _) => true,
(_, Y::Sequence(_)) => false,
(Y::Mapping(_), _) => true,
(_, Y::Mapping(_)) => false,
_ => yaml_value_complexity(candidate) >= yaml_value_complexity(existing),
}
}
fn yaml_value_complexity(value: &serde_yaml::Value) -> usize {
use serde_yaml::Value as Y;
match value {
Y::Null => 0,
Y::Bool(_) | Y::Number(_) | Y::String(_) => 1,
Y::Sequence(items) => 1 + items.iter().map(yaml_value_complexity).sum::<usize>(),
Y::Mapping(map) => {
1 + map
.iter()
.map(|(k, v)| yaml_value_complexity(k) + yaml_value_complexity(v))
.sum::<usize>()
}
Y::Tagged(tagged) => 1 + yaml_value_complexity(&tagged.value),
}
}
fn top_level_yaml_key(trimmed_line: &str) -> Option<String> {
if trimmed_line.starts_with('-') {
return None;
}
yaml_mapping_key(trimmed_line)
}
fn yaml_mapping_key(trimmed_line: &str) -> Option<String> {
let without_comment = trimmed_line
.split_once(" #")
.map(|(left, _)| left)
.unwrap_or(trimmed_line)
.trim_end();
let (key, rest) = without_comment.split_once(':')?;
let key = key.trim();
if key.is_empty() || key.contains(' ') || key.contains('\t') {
return None;
}
let key = key
.strip_prefix('"')
.and_then(|k| k.strip_suffix('"'))
.or_else(|| {
key.strip_prefix('\'')
.and_then(|k| k.strip_suffix('\''))
})
.unwrap_or(key);
if rest.starts_with("//") {
return None;
}
Some(key.to_string())
}
fn line_indent(line: &str) -> usize {
let trimmed = line.trim_start_matches(|c: char| c == ' ' || c == '\t');
line.len().saturating_sub(trimmed.len())
}
fn line_content(raw: &str) -> &str {
raw.strip_suffix('\n').unwrap_or(raw)
}
fn dedupe_yaml_nested_mapping_keys(content: &str) -> (String, bool) {
let mut current = content.to_string();
let mut changed = false;
for _ in 0..64 {
match serde_yaml::from_str::<serde_yaml::Value>(¤t) {
Ok(_) => return (current, changed),
Err(error) => {
let message = error.to_string();
if !message.contains("duplicate entry with key") {
return (current, changed);
}
match heal_one_nested_duplicate_mapping_key(¤t, &message) {
Some(next) if next != current => {
current = next;
changed = true;
}
_ => return (current, changed),
}
}
}
}
(current, changed)
}
fn extract_duplicate_key_error(message: &str) -> Option<(String, usize)> {
let key = {
let marker = "duplicate entry with key \"";
let start = message.find(marker)? + marker.len();
let end = message[start..].find('"')? + start;
message[start..end].to_string()
};
let line = {
let marker = "at line ";
let start = message.find(marker)? + marker.len();
let digits: String = message[start..]
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
digits.parse::<usize>().ok()?
};
if line == 0 {
return None;
}
Some((key, line))
}
fn pure_mapping_key_indent_and_name(line: &str) -> Option<(usize, String)> {
let content = line_content(line);
let trimmed = content.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
return None;
}
let indent = line_indent(content);
let key = yaml_mapping_key(trimmed)?;
Some((indent, key))
}
fn yaml_mapping_key_block_range(
lines: &[String],
key_line_idx: usize,
key_indent: usize,
) -> (usize, usize) {
let mut end = key_line_idx + 1;
while end < lines.len() {
let line = line_content(&lines[end]);
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
let mut look = end + 1;
while look < lines.len() {
let l = line_content(&lines[look]);
let t = l.trim_start();
if t.is_empty() || t.starts_with('#') {
look += 1;
continue;
}
let i = line_indent(l);
if i > key_indent {
break;
}
return (key_line_idx, end);
}
end += 1;
continue;
}
let indent = line_indent(line);
if indent > key_indent {
end += 1;
continue;
}
break;
}
(key_line_idx, end)
}
fn same_mapping_scope(
lines: &[String],
first_idx: usize,
second_idx: usize,
key_indent: usize,
) -> bool {
if second_idx <= first_idx {
return false;
}
for raw in &lines[first_idx + 1..second_idx] {
let line = line_content(raw);
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if line_indent(line) < key_indent {
return false;
}
if line_indent(line) == key_indent.saturating_sub(2) && trimmed.starts_with('-') {
return false;
}
}
true
}
fn heal_one_nested_duplicate_mapping_key(content: &str, error_message: &str) -> Option<String> {
let (key, _reported_line) = extract_duplicate_key_error(error_message)?;
let lines: Vec<String> = content
.split_inclusive('\n')
.map(|s| s.to_string())
.collect();
if lines.is_empty() {
return None;
}
let mut occurrences: Vec<(usize, usize)> = Vec::new();
for (idx, line) in lines.iter().enumerate() {
if let Some((indent, name)) = pure_mapping_key_indent_and_name(line) {
if name == key {
occurrences.push((idx, indent));
}
}
}
let mut pair: Option<(usize, usize, usize)> = None; for window in occurrences.windows(2) {
let (i1, ind1) = window[0];
let (i2, ind2) = window[1];
if ind1 != ind2 {
continue;
}
if same_mapping_scope(&lines, i1, i2, ind1) {
pair = Some((i1, i2, ind1));
break;
}
}
let (prev_idx, dup_idx, key_indent) = pair?;
let (prev_start, prev_end) = yaml_mapping_key_block_range(&lines, prev_idx, key_indent);
let (dup_start, dup_end) = yaml_mapping_key_block_range(&lines, dup_idx, key_indent);
if prev_end > dup_start {
return None;
}
let prev_text = lines[prev_start..prev_end].join("");
let dup_text = lines[dup_start..dup_end].join("");
let prev_val = parse_yaml_key_block_value(&key, &prev_text);
let dup_val = parse_yaml_key_block_value(&key, &dup_text);
let merged_value = match (prev_val, dup_val) {
(Some(mut a), Some(b))
if matches!(
(&a, &b),
(serde_yaml::Value::Mapping(_), serde_yaml::Value::Mapping(_))
) =>
{
deep_merge_yaml(&mut a, b);
a
}
(Some(a), Some(b)) => {
if yaml_value_is_richer(&b, &a) {
b
} else {
a
}
}
(None, Some(b)) => b,
(Some(a), None) => a,
(None, None) => return None,
};
let indent_str = " ".repeat(key_indent);
let mut map = serde_yaml::Mapping::new();
map.insert(serde_yaml::Value::String(key.clone()), merged_value);
let serialized = serde_yaml::to_string(&serde_yaml::Value::Mapping(map)).ok()?;
let mut rendered = String::new();
for ser_line in serialized.lines() {
if ser_line.trim().is_empty() {
continue;
}
rendered.push_str(&indent_str);
rendered.push_str(ser_line);
rendered.push('\n');
}
if rendered.is_empty() {
return None;
}
let mut output = String::with_capacity(content.len());
for line in &lines[..prev_start] {
output.push_str(line);
}
output.push_str(&rendered);
for line in &lines[prev_end..dup_start] {
output.push_str(line);
}
for line in &lines[dup_end..] {
output.push_str(line);
}
Some(output)
}
fn parse_yaml_key_block_value(key: &str, block_text: &str) -> Option<serde_yaml::Value> {
let value: serde_yaml::Value = serde_yaml::from_str(block_text).ok()?;
let mapping = value.as_mapping()?;
mapping
.get(serde_yaml::Value::String(key.to_string()))
.cloned()
.or_else(|| {
mapping.iter().find_map(|(k, v)| {
k.as_str()
.filter(|candidate| *candidate == key)
.map(|_| v.clone())
})
})
}
fn parse_yaml_config_documents(
content: &str,
) -> Result<(serde_yaml::Value, ConfigParseNormalizeMeta), String> {
let had_bom = content.starts_with('\u{feff}');
let stripped = strip_utf8_bom(content);
let (quoted, quoted_colon_keys) = quote_unquoted_yaml_keys_with_colons(stripped);
let (top_deduped, duplicate_top_level_keys) = dedupe_yaml_top_level_keys("ed);
let (deduped, duplicate_nested_keys) = dedupe_yaml_nested_mapping_keys(&top_deduped);
match load_yaml_documents(&deduped) {
Ok(documents) => Ok((
finalize_yaml_documents(documents),
ConfigParseNormalizeMeta {
had_bom,
multi_document: documents_len_is_multi(&deduped),
duplicate_top_level_keys,
duplicate_nested_keys,
quoted_colon_keys,
salvaged_services: false,
},
)),
Err(original_error) => {
let (salvaged, salvaged_services) = salvage_yaml_services_sequence(&deduped);
if !salvaged_services {
return Err(original_error);
}
let documents = load_yaml_documents(&salvaged).map_err(|e| {
format!("{original_error}; service salvage also failed: {e}")
})?;
Ok((
finalize_yaml_documents(documents),
ConfigParseNormalizeMeta {
had_bom,
multi_document: documents_len_is_multi(&salvaged),
duplicate_top_level_keys,
duplicate_nested_keys,
quoted_colon_keys,
salvaged_services: true,
},
))
}
}
}
fn documents_len_is_multi(content: &str) -> bool {
let mut count = 0usize;
for document in serde_yaml::Deserializer::from_str(content) {
match serde_yaml::Value::deserialize(document) {
Ok(serde_yaml::Value::Null) => {}
Ok(_) => {
count += 1;
if count > 1 {
return true;
}
}
Err(_) => return false,
}
}
false
}
fn load_yaml_documents(content: &str) -> Result<Vec<serde_yaml::Value>, String> {
let mut documents = Vec::new();
for document in serde_yaml::Deserializer::from_str(content) {
let value = serde_yaml::Value::deserialize(document).map_err(|e| e.to_string())?;
if !matches!(value, serde_yaml::Value::Null) {
documents.push(value);
}
}
Ok(documents)
}
fn finalize_yaml_documents(documents: Vec<serde_yaml::Value>) -> serde_yaml::Value {
if documents.is_empty() {
return serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
}
if documents.len() == 1 {
return documents.into_iter().next().unwrap_or(serde_yaml::Value::Null);
}
merge_yaml_values(documents)
}
fn quote_unquoted_yaml_keys_with_colons(content: &str) -> (String, bool) {
let mut output = String::with_capacity(content.len() + 32);
let mut changed = false;
for raw_line in content.split_inclusive('\n') {
let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
let newline = if raw_line.ends_with('\n') { "\n" } else { "" };
let trimmed = line.trim_start();
let indent = line.len().saturating_sub(trimmed.len());
if trimmed.is_empty() || trimmed.starts_with('#') {
output.push_str(line);
output.push_str(newline);
continue;
}
let (list_prefix, mapping_body) = if let Some(rest) = trimmed.strip_prefix('-') {
let rest = rest.trim_start();
if rest.is_empty() || rest.starts_with('#') {
output.push_str(line);
output.push_str(newline);
continue;
}
let dash_ws_len = trimmed.len() - rest.len();
(&trimmed[..dash_ws_len], rest)
} else {
("", trimmed)
};
if mapping_body.starts_with('"') || mapping_body.starts_with('\'') {
output.push_str(line);
output.push_str(newline);
continue;
}
if let Some((key, rest)) = split_yaml_mapping_key(mapping_body) {
if key.contains(':') && !key.starts_with('"') && !key.starts_with('\'') {
for _ in 0..indent {
output.push(' ');
}
output.push_str(list_prefix);
output.push('"');
output.push_str(key);
output.push('"');
output.push(':');
output.push_str(rest);
output.push_str(newline);
changed = true;
continue;
}
}
output.push_str(line);
output.push_str(newline);
}
(output, changed)
}
fn split_yaml_mapping_key(mapping_body: &str) -> Option<(&str, &str)> {
let without_comment = mapping_body
.split_once(" #")
.map(|(left, _)| left)
.unwrap_or(mapping_body);
let bytes = without_comment.as_bytes();
let mut candidates = Vec::new();
for (idx, ch) in without_comment.char_indices() {
if ch != ':' {
continue;
}
let after = idx + 1;
if after >= without_comment.len() {
candidates.push(idx);
continue;
}
let next = bytes[after] as char;
if next.is_whitespace() {
candidates.push(idx);
}
}
let sep = candidates.last().copied().or_else(|| {
without_comment.rfind(':')
})?;
let key = without_comment[..sep].trim_end();
if key.is_empty() || key.contains(' ') || key.contains('\t') {
return None;
}
let rest = &without_comment[sep + 1..];
if !key.contains(':') && rest.starts_with("//") {
return None;
}
Some((key, rest))
}
fn salvage_yaml_services_sequence(content: &str) -> (String, bool) {
let lines: Vec<&str> = content.split_inclusive('\n').collect();
let mut services_line_idx = None;
for (idx, raw) in lines.iter().enumerate() {
let line = raw.strip_suffix('\n').unwrap_or(raw);
if line == "services:" || line.starts_with("services:") && !line[9..].contains('|') {
if !line.starts_with(' ') && !line.starts_with('\t') {
services_line_idx = Some(idx);
break;
}
}
}
let Some(start) = services_line_idx else {
return (content.to_string(), false);
};
let mut item_starts: Vec<usize> = Vec::new();
let mut end = lines.len();
for idx in (start + 1)..lines.len() {
let line = lines[idx].strip_suffix('\n').unwrap_or(lines[idx]);
let trimmed = line.trim_start();
let indent = line.len().saturating_sub(trimmed.len());
if indent == 0 && !trimmed.is_empty() && !trimmed.starts_with('#') {
if top_level_yaml_key(trimmed).is_some() {
end = idx;
break;
}
if trimmed.starts_with('-') {
item_starts.push(idx);
}
} else if indent == 0 && trimmed.starts_with('-') {
item_starts.push(idx);
}
}
if item_starts.is_empty() {
return (content.to_string(), false);
}
let mut kept_items: Vec<String> = Vec::new();
let mut dropped = 0usize;
for (i, item_start) in item_starts.iter().enumerate() {
let item_end = item_starts.get(i + 1).copied().unwrap_or(end);
let mut item_text = String::new();
for raw in &lines[*item_start..item_end] {
item_text.push_str(raw);
}
let probe = format!("services:\n{item_text}");
match serde_yaml::from_str::<serde_yaml::Value>(&probe) {
Ok(serde_yaml::Value::Mapping(map)) => {
let ok = map
.get(serde_yaml::Value::String("services".into()))
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.len() == 1
&& seq[0]
.as_mapping()
.map(|m| {
m.keys().any(|k| {
k.as_str() == Some("name") || k.as_str() == Some("target")
})
})
.unwrap_or(false)
})
.unwrap_or(false);
if ok {
kept_items.push(item_text);
} else {
dropped += 1;
}
}
_ => dropped += 1,
}
}
if dropped == 0 {
return (content.to_string(), false);
}
let mut output = String::with_capacity(content.len());
for raw in &lines[..=start] {
output.push_str(raw);
}
for item in &kept_items {
output.push_str(item);
}
for raw in &lines[end..] {
output.push_str(raw);
}
(output, true)
}
fn merge_yaml_values(documents: Vec<serde_yaml::Value>) -> serde_yaml::Value {
let mut merged = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
for document in documents {
deep_merge_yaml(&mut merged, document);
}
merged
}
fn deep_merge_yaml(base: &mut serde_yaml::Value, incoming: serde_yaml::Value) {
match (base, incoming) {
(serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(incoming_map)) => {
for (key, value) in incoming_map {
match base_map.get_mut(&key) {
Some(existing) => deep_merge_yaml(existing, value),
None => {
base_map.insert(key, value);
}
}
}
}
(base_slot, incoming) => {
*base_slot = incoming;
}
}
}
pub fn parse_config_with_auto_heal<T: DeserializeOwned>(
content: &str,
kind: &str,
) -> Result<(T, Option<String>), String> {
let (mut value, structural_heal) = match kind {
"yaml" => {
let (yaml_value, meta) = parse_yaml_config_documents(content)?;
let json = serde_json::to_value(yaml_value).map_err(|e| e.to_string())?;
(json, meta.needs_rewrite())
}
"json" => {
let had_bom = content.starts_with('\u{feff}');
let stripped = strip_utf8_bom(content);
let json = serde_json::from_str::<Value>(stripped).map_err(|e| e.to_string())?;
(json, had_bom)
}
"jsonc" => {
let stripped = strip_jsonc_comments_light(strip_utf8_bom(content));
let json = serde_json::from_str::<Value>(&stripped).map_err(|e| e.to_string())?;
(json, stripped != content)
}
"toml" => {
let value = toml::from_str::<toml::Value>(strip_utf8_bom(content))
.map_err(|e| e.to_string())?;
let json = serde_json::to_value(value).map_err(|e| e.to_string())?;
(json, false)
}
_ => return Err(format!("Unsupported config kind: {}", kind)),
};
let field_healed = auto_heal_xbp_config_value(&mut value);
let healed = field_healed || structural_heal;
let parsed = serde_json::from_value::<T>(value.clone()).map_err(|e| e.to_string())?;
let healed_content = if healed {
Some(match kind {
"yaml" => serialize_xbp_yaml(&value)?,
"json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
"jsonc" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
"toml" => render_xbp_config_value(&value, "toml")?,
_ => unreachable!(),
})
} else {
None
};
Ok((parsed, healed_content))
}
pub fn config_kind_from_path(path: &Path) -> Result<&'static str, String> {
match path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"yaml" | "yml" => Ok("yaml"),
"json" => Ok("json"),
"jsonc" => Ok("jsonc"),
"toml" => Ok("toml"),
other => Err(format!("Unsupported XBP config extension `{other}`.")),
}
}
pub fn render_xbp_config_value(value: &Value, kind: &str) -> Result<String, String> {
render_xbp_config_value_for_path(value, kind, None)
}
pub fn render_xbp_config_value_for_path(
value: &Value,
kind: &str,
config_path: Option<&Path>,
) -> Result<String, String> {
let mut value = value.clone();
let _ = ensure_named_entry_key_order(&mut value);
match kind {
"yaml" | "yml" => {
let yaml = serde_yaml::to_string(&value)
.map_err(|e| format!("Failed to serialize YAML config: {e}"))?;
let body = normalize_xbp_yaml_environment_quotes(&yaml);
let directive = config_path
.map(xbp_project_schema_directive_for_path)
.unwrap_or_else(|| XBP_PROJECT_YAML_SCHEMA_DIRECTIVE.to_string());
Ok(ensure_xbp_yaml_schema_directive_with(&body, &directive))
}
"json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string()),
"jsonc" => {
serde_json::to_string_pretty(&value).map_err(|e| e.to_string())
}
"toml" => {
let toml_value = json_value_to_toml(&value)?;
toml::to_string_pretty(&toml_value).map_err(|e| e.to_string())
}
_ => Err(format!("Unsupported XBP config kind `{kind}`.")),
}
}
fn json_value_to_toml(value: &Value) -> Result<toml::Value, String> {
match value {
Value::Object(entries) => {
let mut table = toml::map::Map::new();
for (key, value) in entries {
if value.is_null() {
continue;
}
table.insert(key.clone(), json_value_to_toml(value)?);
}
Ok(toml::Value::Table(table))
}
Value::Array(values) => Ok(toml::Value::Array(
values
.iter()
.map(json_value_to_toml)
.collect::<Result<_, _>>()?,
)),
Value::String(value) => Ok(toml::Value::String(value.clone())),
Value::Bool(value) => Ok(toml::Value::Boolean(*value)),
Value::Number(value) => value
.as_i64()
.map(toml::Value::Integer)
.or_else(|| {
value
.as_u64()
.and_then(|number| i64::try_from(number).ok())
.map(toml::Value::Integer)
})
.or_else(|| value.as_f64().map(toml::Value::Float))
.ok_or_else(|| "Unsupported JSON number in TOML conversion.".to_string()),
Value::Null => Err("Null cannot be represented in TOML.".to_string()),
}
}
pub fn strip_jsonc_comments_light(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut index = 0usize;
let mut in_string = false;
let mut escaped = false;
while index < bytes.len() {
let character = bytes[index] as char;
if in_string {
output.push(character);
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
in_string = false;
}
index += 1;
continue;
}
if character == '"' {
in_string = true;
output.push(character);
index += 1;
continue;
}
if character == '/' && index + 1 < bytes.len() {
match bytes[index + 1] as char {
'/' => {
index += 2;
while index < bytes.len() && bytes[index] != b'\n' {
index += 1;
}
continue;
}
'*' => {
index += 2;
while index + 1 < bytes.len()
&& !(bytes[index] == b'*' && bytes[index + 1] == b'/')
{
index += 1;
}
index = (index + 2).min(bytes.len());
continue;
}
_ => {}
}
}
if character == ',' {
let mut lookahead = index + 1;
while lookahead < bytes.len() && (bytes[lookahead] as char).is_whitespace() {
lookahead += 1;
}
if lookahead < bytes.len() && matches!(bytes[lookahead] as char, '}' | ']') {
index += 1;
continue;
}
}
output.push(character);
index += 1;
}
output
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct XbpConfigHealResult {
pub config_path: PathBuf,
pub fixes: Vec<String>,
}
pub fn detect_xbp_config_heal_opportunities(content: &str) -> Vec<String> {
let mut fixes = Vec::new();
if content.starts_with('\u{feff}') {
fixes.push("strip leading UTF-8 BOM".to_string());
}
let stripped = strip_utf8_bom(content);
if content_looks_like_yaml_multidoc(stripped) {
fixes.push("collapse multi-document YAML into a single document".to_string());
}
let mut top_level_keys: HashMap<String, usize> = HashMap::new();
for line in stripped.lines() {
let trimmed = line.trim_start();
if line.len() != trimmed.len() {
continue;
}
if let Some(key) = top_level_yaml_key(trimmed) {
*top_level_keys.entry(key).or_insert(0) += 1;
}
}
let mut duplicate_keys: Vec<_> = top_level_keys
.into_iter()
.filter(|(_, count)| *count > 1)
.map(|(key, count)| format!("resolve {count} duplicate top-level `{key}` blocks"))
.collect();
duplicate_keys.sort();
fixes.extend(duplicate_keys);
let stripped_for_nested = strip_utf8_bom(content);
if serde_yaml::from_str::<serde_yaml::Value>(stripped_for_nested)
.ok()
.is_none()
{
let (nested_healed, nested_changed) =
dedupe_yaml_nested_mapping_keys(stripped_for_nested);
if nested_changed
&& serde_yaml::from_str::<serde_yaml::Value>(&nested_healed).is_ok()
{
fixes.push(
"resolve duplicate nested mapping keys (e.g. services[].environment)".to_string(),
);
} else if let Err(err) = serde_yaml::from_str::<serde_yaml::Value>(stripped_for_nested) {
let msg = err.to_string();
if msg.contains("duplicate entry with key") {
fixes.push(format!(
"resolve nested YAML duplicate keys ({msg})"
));
}
}
}
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("systemd:") {
continue;
}
let value = trimmed.trim_start_matches("systemd:").trim();
if value.is_empty() || value == "null" || value.starts_with('{') {
continue;
}
let normalized = value.trim_matches('"').trim_matches('\'');
fixes.push(format!(
"normalize `systemd: {normalized}` to `systemd_service_name`"
));
}
fixes.sort();
fixes.dedup();
fixes
}
fn content_looks_like_yaml_multidoc(content: &str) -> bool {
let mut document_count = 0usize;
for document in serde_yaml::Deserializer::from_str(content) {
match serde_yaml::Value::deserialize(document) {
Ok(serde_yaml::Value::Null) => {}
Ok(_) => {
document_count += 1;
if document_count > 1 {
return true;
}
}
Err(_) => return false,
}
}
false
}
pub fn heal_project_xbp_config(start_dir: &Path) -> Result<Option<XbpConfigHealResult>, String> {
let found = match find_xbp_config_upwards(start_dir) {
Some(found) => found,
None => return Ok(None),
};
let content = fs::read_to_string(&found.config_path)
.map_err(|e| format!("Failed to read {}: {}", found.config_path.display(), e))?;
let fixes = detect_xbp_config_heal_opportunities(&content);
let healed = heal_config_file(&found.config_path, found.kind)?;
if !healed && fixes.is_empty() {
return Ok(None);
}
Ok(Some(XbpConfigHealResult {
config_path: found.config_path,
fixes: if fixes.is_empty() {
vec!["normalized legacy config fields".to_string()]
} else {
fixes
},
}))
}
pub fn heal_config_file(path: &Path, kind: &str) -> Result<bool, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let (_, healed_content) = parse_config_with_auto_heal::<Value>(&content, kind)?;
if let Some(healed_content) = healed_content {
fs::write(path, healed_content)
.map_err(|e| format!("Failed to write healed config {}: {}", path.display(), e))?;
return Ok(true);
}
Ok(false)
}
pub(crate) fn normalize_xbp_config_value(value: &mut Value) -> bool {
auto_heal_xbp_config_value(value)
}
fn auto_heal_xbp_config_value(value: &mut Value) -> bool {
let mut changed = ensure_named_entry_key_order(value);
let Some(root) = value.as_object_mut() else {
return changed;
};
if let Some(environment) = root.get_mut("environment") {
changed |= normalize_environment_value(environment);
}
changed |= normalize_systemd_shorthand(root);
if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
for service in services {
let Some(service) = service.as_object_mut() else {
continue;
};
if let Some(environment) = service.get_mut("environment") {
changed |= normalize_environment_value(environment);
}
changed |= normalize_systemd_shorthand(service);
changed |= normalize_boolish_fields(service, SERVICE_BOOL_KEYS);
}
}
changed |= ensure_named_entry_key_order(value);
changed
}
const SERVICE_BOOL_KEYS: &[&str] = &[
"target_freeze",
"freeze_target",
"target_frozen",
"force_run_from_root",
"versioning",
"versioning_enabled",
"enable_versioning",
"release",
"release_enabled",
"enable_release",
];
fn normalize_boolish_fields(map: &mut Map<String, Value>, keys: &[&str]) -> bool {
let mut changed = false;
for key in keys {
let Some(value) = map.get_mut(*key) else {
continue;
};
if let Some(coerced) = coerce_boolish_value(value) {
*value = coerced;
changed = true;
}
}
changed
}
fn coerce_boolish_value(value: &Value) -> Option<Value> {
match value {
Value::Bool(_) => None,
Value::String(raw) => {
let trimmed = raw.trim();
if trimmed.eq_ignore_ascii_case("true")
|| trimmed.eq_ignore_ascii_case("yes")
|| trimmed.eq_ignore_ascii_case("on")
|| trimmed == "1"
{
Some(Value::Bool(true))
} else if trimmed.eq_ignore_ascii_case("false")
|| trimmed.eq_ignore_ascii_case("no")
|| trimmed.eq_ignore_ascii_case("off")
|| trimmed == "0"
{
Some(Value::Bool(false))
} else {
None
}
}
Value::Number(number) => {
if number.as_u64() == Some(1) || number.as_i64() == Some(1) {
Some(Value::Bool(true))
} else if number.as_u64() == Some(0) || number.as_i64() == Some(0) {
Some(Value::Bool(false))
} else {
None
}
}
_ => None,
}
}
const SERVICE_KEY_ORDER: &[&str] = &[
"name",
"target",
"target_freeze",
"branch",
"port",
"root_directory",
"environment",
"url",
"healthcheck_path",
"restart_policy",
"restart_policy_max_failure_count",
"start_wrapper",
"commands",
"force_run_from_root",
"version_targets",
"depends_on",
"watch_paths",
"versioning",
"release",
"systemd_service_name",
"systemd",
"openapi",
"oci",
"deploy",
"discord",
];
const WORKER_KEY_ORDER: &[&str] = &[
"name",
"root",
"script_name",
"service",
"deploy",
"container",
"containers",
"durable_objects",
];
fn ensure_named_entry_key_order(value: &mut Value) -> bool {
let Some(root) = value.as_object_mut() else {
return false;
};
let mut changed = false;
if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
for service in services {
if let Some(obj) = service.as_object_mut() {
changed |= reorder_object_keys(obj, SERVICE_KEY_ORDER);
}
}
}
if let Some(workers) = root.get_mut("workers").and_then(Value::as_array_mut) {
for worker in workers {
if let Some(obj) = worker.as_object_mut() {
changed |= reorder_object_keys(obj, WORKER_KEY_ORDER);
}
}
}
changed
}
fn reorder_object_keys(map: &mut Map<String, Value>, preferred: &[&str]) -> bool {
if map.is_empty() {
return false;
}
let current_keys: Vec<String> = map.keys().cloned().collect();
let mut desired: Vec<String> = Vec::with_capacity(current_keys.len());
for key in preferred {
if map.contains_key(*key) {
desired.push((*key).to_string());
}
}
for key in ¤t_keys {
if !desired.iter().any(|k| k == key) {
desired.push(key.clone());
}
}
if current_keys == desired {
return false;
}
let mut reordered = Map::new();
for key in &desired {
if let Some(value) = map.remove(key) {
reordered.insert(key.clone(), value);
}
}
let leftovers: Vec<(String, Value)> = map.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
for (key, value) in leftovers {
map.remove(&key);
reordered.insert(key, value);
}
*map = reordered;
true
}
fn normalize_systemd_shorthand(map: &mut Map<String, Value>) -> bool {
let Some(systemd) = map.get("systemd") else {
return false;
};
let Value::String(service_name) = systemd else {
return false;
};
let systemd_service_name_missing = map
.get("systemd_service_name")
.map(|value| value.is_null())
.unwrap_or(true);
if systemd_service_name_missing && !service_name.is_empty() {
map.insert(
"systemd_service_name".to_string(),
Value::String(service_name.clone()),
);
}
map.remove("systemd");
true
}
fn normalize_environment_value(value: &mut Value) -> bool {
let Value::Object(map) = value else {
return false;
};
let original = map.clone();
let mut normalized = Map::new();
let mut changed = false;
flatten_environment_entries(&original, &mut normalized, &mut changed);
if normalized != original {
*map = normalized;
changed = true;
}
changed
}
fn flatten_environment_entries(
source: &Map<String, Value>,
target: &mut Map<String, Value>,
changed: &mut bool,
) {
for (key, value) in source {
match value {
Value::String(string) => {
let cleaned = unwrap_redundant_env_string_quotes(string);
if cleaned != *string {
*changed = true;
}
target.insert(key.clone(), Value::String(cleaned));
}
Value::Number(number) => {
*changed = true;
target.insert(key.clone(), Value::String(number.to_string()));
}
Value::Bool(boolean) => {
*changed = true;
target.insert(key.clone(), Value::String(boolean.to_string()));
}
Value::Null => {
*changed = true;
target.insert(key.clone(), Value::String(String::new()));
}
Value::Array(items) => {
*changed = true;
let serialized = serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string());
target.insert(key.clone(), Value::String(serialized));
}
Value::Object(nested) => {
*changed = true;
flatten_environment_entries(nested, target, changed);
}
}
}
}
fn unwrap_redundant_env_string_quotes(value: &str) -> String {
let mut current = value.trim().to_string();
for _ in 0..4 {
let next = unwrap_one_env_quote_layer(¤t);
if next == current {
break;
}
current = next;
}
current
}
fn unwrap_one_env_quote_layer(value: &str) -> String {
let trimmed = value.trim();
if trimmed.len() < 2 {
return trimmed.to_string();
}
if trimmed.starts_with('"') && trimmed.ends_with('"') {
if env_string_is_fully_double_quoted(trimmed) {
let inner = &trimmed[1..trimmed.len() - 1];
return expand_basic_double_quoted_escapes(inner);
}
return trimmed.to_string();
}
if trimmed.starts_with('\'') && trimmed.ends_with('\'') {
if env_string_is_fully_single_quoted(trimmed) {
let inner = &trimmed[1..trimmed.len() - 1];
return inner.replace("''", "'");
}
}
trimmed.to_string()
}
fn env_string_is_fully_double_quoted(value: &str) -> bool {
if !value.starts_with('"') || value.len() < 2 {
return false;
}
let bytes = value.as_bytes();
let mut i = 1usize;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 2;
continue;
}
if bytes[i] == b'"' {
return i + 1 == bytes.len();
}
i += 1;
}
false
}
fn env_string_is_fully_single_quoted(value: &str) -> bool {
if !value.starts_with('\'') || !value.ends_with('\'') || value.len() < 2 {
return false;
}
let inner = &value[1..value.len() - 1];
let mut chars = inner.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\'' {
if chars.peek() == Some(&'\'') {
chars.next();
continue;
}
return false;
}
}
true
}
fn expand_basic_double_quoted_escapes(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\\' {
out.push(ch);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some('\'') => out.push('\''),
Some(other) => out.push(other),
None => out.push('\\'),
}
}
out
}
#[cfg(test)]
mod tests {
use super::{
command_exists, default_project_config_path, detect_xbp_config_heal_opportunities,
find_xbp_config_upwards, first_available_command, git_remote_url_from_metadata,
maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal,
parse_github_repo_from_remote_url, preferred_pip_command, preferred_python_command,
redact_remote_url_credentials, render_xbp_config_value, resolve_xbp_project_for_path,
write_json_config_from_any_xbp_config, write_xbp_project_config_at_path, KnownXbpProject,
};
use crate::strategies::XbpConfig;
use serde_json::Value;
use std::fs;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
fn path_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn make_temp_path(name: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("xbp-test-{}-{}", name, std::process::id()));
path
}
fn with_path<F>(entries: &[PathBuf], test: F)
where
F: FnOnce(),
{
let _guard = path_lock().lock().expect("path lock should be available");
let original = std::env::var_os("PATH");
let joined = std::env::join_paths(entries).expect("PATH entries should join");
std::env::set_var("PATH", joined);
test();
match original {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
#[test]
fn parses_yaml_with_leading_utf8_bom_and_rewrites_clean_file() {
let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n";
let (config, healed_content) =
parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("BOM yaml should parse");
assert_eq!(config.project_name, "demo");
assert_eq!(config.port, 3000);
let healed = healed_content.expect("BOM should trigger autonomous rewrite");
assert!(
!healed.starts_with('\u{feff}'),
"healed yaml must not retain BOM"
);
assert!(healed.contains("project_name: demo"));
}
#[test]
fn heals_duplicate_nested_environment_under_service() {
let yaml = r#"
project_name: demo
version: 1.0.0
port: 3000
build_dir: ./
services:
- name: app
target: nextjs
branch: main
port: 3000
environment:
A: "1"
SHARED: first
root_directory: ./
environment:
B: "2"
SHARED: second
"#;
let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
.expect("duplicate nested environment should heal");
let services = config.services.expect("services");
assert_eq!(services.len(), 1);
let env = services[0]
.environment
.as_ref()
.expect("merged environment");
assert_eq!(env.get("A"), Some(&"1".to_string()));
assert_eq!(env.get("B"), Some(&"2".to_string()));
assert_eq!(
env.get("SHARED"),
Some(&"second".to_string()),
"later environment block should win on scalar conflicts"
);
let healed = healed_content.expect("should rewrite healed yaml");
let env_keys_in_first_service = healed
.lines()
.filter(|line| *line == " environment:" || line.starts_with(" environment: "))
.count();
assert_eq!(
env_keys_in_first_service, 1,
"exactly one environment key under the service: {healed}"
);
assert!(healed.contains("A:") || healed.contains("A: "));
assert!(healed.contains("B:") || healed.contains("B: "));
}
#[test]
fn discovers_and_round_trips_all_project_config_formats() {
let root = make_temp_path("multi-format-config");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".xbp")).expect("mkdir");
let baseline = serde_json::json!({
"project_name": "multi",
"port": 3000,
"build_dir": "./",
"services": [{
"name": "api",
"target": "rust",
"branch": "main",
"port": 8080
}]
});
for kind in ["yaml", "json", "jsonc", "toml"] {
let path = default_project_config_path(&root, kind);
write_xbp_project_config_at_path(&path, &baseline).expect("write");
assert!(path.exists(), "{kind} path missing");
let found = find_xbp_config_upwards(&root).expect("discover");
assert_eq!(found.kind, if kind == "yml" { "yaml" } else { kind });
assert_eq!(found.config_path, path);
let content = fs::read_to_string(&path).expect("read");
let (parsed, _) =
parse_config_with_auto_heal::<Value>(&content, found.kind).expect("parse");
assert_eq!(parsed["project_name"], "multi");
assert_eq!(parsed["services"][0]["name"], "api");
write_xbp_project_config_at_path(&path, &parsed).expect("rewrite");
let found_again = find_xbp_config_upwards(&root).expect("rediscover");
assert_eq!(found_again.config_path, path);
let _ = fs::remove_file(&path);
}
let _ = fs::remove_dir_all(&root);
}
#[test]
fn service_name_key_is_first_in_yaml_and_toml() {
let value = serde_json::json!({
"project_name": "demo",
"port": 3000,
"build_dir": "./",
"services": [{
"branch": "main",
"port": 3000,
"target": "nextjs",
"name": "app",
"root_directory": "./"
}],
"workers": [{
"root": "apps/api",
"name": "api"
}]
});
for kind in ["yaml", "toml", "json"] {
let rendered = render_xbp_config_value(&value, kind).expect("render");
match kind {
"yaml" => {
let mut saw_service_item = false;
let mut first_key: Option<String> = None;
for line in rendered.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("- ") || trimmed.starts_with("- name:") {
saw_service_item = true;
if let Some(rest) = trimmed.strip_prefix("- ") {
if let Some((key, _)) = rest.split_once(':') {
first_key = Some(key.trim().to_string());
break;
}
}
continue;
}
if saw_service_item && !trimmed.is_empty() && !trimmed.starts_with('#') {
if let Some((key, _)) = trimmed.split_once(':') {
first_key = Some(key.trim().to_string());
break;
}
}
}
assert_eq!(
first_key.as_deref(),
Some("name"),
"yaml service first key should be name:\n{rendered}"
);
}
"toml" => {
let services_idx = rendered.find("[[services]]").expect("services table");
let after = &rendered[services_idx..];
let name_idx = after.find("name = ").expect("name key");
let branch_idx = after.find("branch = ").expect("branch key");
assert!(
name_idx < branch_idx,
"toml services name must precede branch:\n{rendered}"
);
}
"json" => {
let parsed: Value =
serde_json::from_str(&rendered).expect("json parse");
let keys: Vec<&str> = parsed["services"][0]
.as_object()
.expect("service object")
.keys()
.map(|k| k.as_str())
.collect();
assert_eq!(keys.first().copied(), Some("name"), "json keys: {keys:?}");
}
_ => {}
}
}
}
#[test]
fn heals_double_quoted_environment_string_values() {
let yaml = r#"
project_name: demo
version: 1.0.0
port: 3000
build_dir: ./
environment:
APPLE_CLIENT_ID: '"com.suitsconnect"'
ATHENA_URL: '"https://mirror1.athena-cluster.com"'
PLACEHOLDER: ${DATABASE_URL}
JSON_OK: '{"a":1}'
services:
- name: app
target: nextjs
branch: main
port: 3000
environment:
BETTER_AUTH_SECRET: '"380d1e84385f4b56ce2f39b56da618c9d8e94de6af2a034bdc1f0105ed63590e"'
BARE: plain
"#;
let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
.expect("double-quoted env values should heal");
let root_env = config.environment.expect("root environment");
assert_eq!(
root_env.get("APPLE_CLIENT_ID"),
Some(&"com.suitsconnect".to_string())
);
assert_eq!(
root_env.get("ATHENA_URL"),
Some(&"https://mirror1.athena-cluster.com".to_string())
);
assert_eq!(
root_env.get("PLACEHOLDER"),
Some(&"${DATABASE_URL}".to_string())
);
assert_eq!(root_env.get("JSON_OK"), Some(&r#"{"a":1}"#.to_string()));
let svc_env = config
.services
.as_ref()
.and_then(|s| s.first())
.and_then(|s| s.environment.as_ref())
.expect("service environment");
assert_eq!(
svc_env.get("BETTER_AUTH_SECRET"),
Some(&"380d1e84385f4b56ce2f39b56da618c9d8e94de6af2a034bdc1f0105ed63590e".to_string())
);
assert_eq!(svc_env.get("BARE"), Some(&"plain".to_string()));
let healed = healed_content.expect("quote unwrap should rewrite config");
assert!(
!healed.contains("'\"com.suitsconnect\"'"),
"healed yaml must not keep nested quote wrap: {healed}"
);
assert!(
healed.contains("com.suitsconnect"),
"healed yaml should keep unwrapped value: {healed}"
);
}
#[test]
fn drops_duplicate_top_level_openapi_null_and_keeps_first_block() {
let yaml = r#"
project_name: athena
version: 1.0.0
port: 4052
build_dir: ./
openapi:
enabled: true
auto_detect_services: false
services:
- name: athena
target: rust
branch: main
port: 4052
openapi: null
openapi: null
workers:
- name: athena-auth
root: services/athena-auth
"#;
let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
.expect("duplicate openapi key should be healed");
assert_eq!(config.project_name, "athena");
assert_eq!(
config.openapi.as_ref().and_then(|o| o.enabled),
Some(true)
);
assert!(config.workers.is_some());
let healed = healed_content.expect("duplicate key should trigger rewrite");
let top_level_openapi_lines = healed
.lines()
.filter(|line| line.starts_with("openapi:"))
.count();
assert_eq!(
top_level_openapi_lines, 1,
"healed yaml should keep a single top-level openapi block: {healed}"
);
assert!(
!healed.contains("\nopenapi: null\n") && !healed.ends_with("openapi: null"),
"stray top-level openapi: null must be dropped: {healed}"
);
}
#[test]
fn heals_duplicate_services_keeping_richer_column0_sequence() {
let yaml = r#"
project_name: athena
version: 4.0.2
port: 4052
build_dir: ./
environment: {}
services:
- name: athena
target: docker-compose
branch: main
port: 3001
npm:
enabled: true
package_name: '@xylex-group/athena'
release_disabled: []
services:
- branch: main
name: athena
target: docker-compose
port: 3001
commands:
build: cargo build --release
start: cargo run
- branch: main
name: athena-studio
target: nextjs
port: 3000
version: 4.0.2
release_disabled: []
"#;
let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
.expect("duplicate services with column-0 list items should heal");
assert_eq!(config.project_name, "athena");
let services = config.services.expect("services should load");
assert_eq!(services.len(), 2, "should keep the fuller services list");
assert_eq!(services[0].name, "athena");
assert_eq!(services[1].name, "athena-studio");
let healed = healed_content.expect("duplicate services should rewrite");
assert_eq!(
healed.lines().filter(|line| line.starts_with("services:")).count(),
1,
"exactly one services key: {healed}"
);
assert!(healed.contains("athena-studio"));
assert!(
!healed.contains("package_name: '@xylex-group/athena'")
|| healed.contains("athena-studio"),
"richer second services block must win"
);
}
#[test]
fn heals_unquoted_colon_command_keys_and_broken_service_items() {
let yaml = r#"
project_name: demo
version: 1.0.0
port: 3000
build_dir: ./
services:
- name: api
target: rust
branch: main
port: 3001
commands:
build: cargo build
deploy:production: cargo run --release
- branch: main
format: npm run format
lint: npm run lint
- name: web
target: nextjs
branch: main
port: 3000
commands:
start: pnpm start
"#;
let (config, healed) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
.expect("colon keys + broken service item should heal");
let services = config.services.expect("services");
assert_eq!(services.len(), 2);
assert_eq!(services[0].name, "api");
assert_eq!(services[1].name, "web");
let commands = services[0].commands.as_ref().expect("api commands");
assert!(
commands.custom.contains_key("deploy:production"),
"quoted colon key should load as deploy:production: {commands:?}"
);
let healed = healed.expect("should rewrite healed yaml");
assert!(healed.contains("\"deploy:production\"") || healed.contains("deploy:production"));
assert!(!healed.contains("\n format: npm run format\n"));
}
#[test]
fn merges_multi_document_yaml_and_rewrites_single_document() {
let yaml = r#"
project_name: demo
port: 3000
build_dir: $HOME/demo
---
environment:
LOG_LEVEL: info
---
services:
- name: api
target: rust
branch: main
port: 3001
"#;
let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
.expect("multi-document yaml should merge");
assert_eq!(config.project_name, "demo");
assert_eq!(config.port, 3000);
let environment = config.environment.expect("merged environment should exist");
assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
let services = config.services.expect("merged services should exist");
assert_eq!(services.len(), 1);
assert_eq!(services[0].name, "api");
let healed = healed_content.expect("multi-doc should trigger rewrite");
assert!(
!healed.lines().any(|line| line.trim() == "---"),
"healed yaml should be a single document"
);
assert!(healed.contains("project_name: demo"));
assert!(healed.contains("LOG_LEVEL"));
assert!(healed.contains("name: api"));
}
#[test]
fn parses_bom_prefixed_multi_document_yaml() {
let yaml = "\u{feff}project_name: demo\nport: 3000\nbuild_dir: ./\n---\nversion: 1.2.3\n";
let (config, healed_content) = parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml")
.expect("BOM + multi-doc yaml should parse");
assert_eq!(config.project_name, "demo");
assert_eq!(config.version, "1.2.3");
assert!(healed_content.is_some());
}
#[test]
fn ensure_xbp_yaml_schema_directive_is_idempotent() {
let bare = "project_name: demo\nport: 3000\nbuild_dir: .\n";
let once = super::ensure_xbp_yaml_schema_directive(bare);
assert!(once.starts_with("# yaml-language-server: $schema="));
assert!(once.contains("project_name: demo"));
let twice = super::ensure_xbp_yaml_schema_directive(&once);
assert_eq!(
once.matches("yaml-language-server").count(),
1,
"should not duplicate schema directive"
);
assert_eq!(twice, once);
let with_doc = "---\nproject_name: demo\n";
let headed = super::ensure_xbp_yaml_schema_directive(with_doc);
assert!(headed.starts_with("---\n# yaml-language-server:"));
}
#[test]
fn rewrites_legacy_xbp_repo_schema_url_to_mirrors() {
let legacy = concat!(
"# yaml-language-server: $schema=https://raw.githubusercontent.com/xylex-group/xbp/main/schemas/xbp.project.schema.json\n",
"project_name: demo\nport: 3000\nbuild_dir: .\n",
);
let fixed = super::ensure_xbp_yaml_schema_directive(legacy);
assert!(
fixed.contains(super::XBP_PROJECT_YAML_SCHEMA_URL),
"should rewrite to xbp-mirrors schema URL: {fixed}"
);
assert!(
fixed.contains("xbp-mirrors"),
"canonical remote is xbp-mirrors: {fixed}"
);
assert!(
!fixed.contains("xylex-group/xbp/main/schemas"),
"legacy repo-path schema URL must be replaced: {fixed}"
);
assert_eq!(fixed.matches("yaml-language-server").count(), 1);
let mirrors = concat!(
"# yaml-language-server: $schema=https://raw.githubusercontent.com/xylex-group/xbp-mirrors/refs/heads/main/xbp.project.schema.json\n",
"project_name: demo\nport: 3000\nbuild_dir: .\n",
);
let kept = super::ensure_xbp_yaml_schema_directive(mirrors);
assert_eq!(kept, mirrors);
}
#[test]
fn local_schema_directive_for_dot_xbp_config() {
let root = make_temp_path("local-schema-dir");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("schemas")).expect("schemas");
fs::create_dir_all(root.join(".xbp")).expect(".xbp");
fs::write(
root.join("schemas").join("xbp.project.schema.json"),
r#"{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}"#,
)
.expect("schema file");
let config_path = root.join(".xbp").join("xbp.yaml");
let directive = super::xbp_project_schema_directive_for_path(&config_path);
assert!(
directive.contains("../schemas/xbp.project.schema.json")
|| directive.contains("schemas/xbp.project.schema.json"),
"expected relative local schema, got {directive}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn detect_heal_opportunities_reports_bom_and_multidoc() {
let yaml = "\u{feff}project_name: demo\n---\nsystemd: athena.service\n";
let fixes = detect_xbp_config_heal_opportunities(yaml);
assert!(
fixes.iter().any(|fix| fix.contains("UTF-8 BOM")),
"expected BOM fix, got {fixes:?}"
);
assert!(
fixes.iter().any(|fix| fix.contains("multi-document")),
"expected multi-doc fix, got {fixes:?}"
);
assert!(
fixes.iter().any(|fix| fix.contains("systemd")),
"expected systemd fix, got {fixes:?}"
);
}
#[test]
fn heal_config_file_strips_bom_on_disk() {
let project_root = make_temp_path("heal-bom-yaml");
fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
let yaml_path = project_root.join(".xbp").join("xbp.yaml");
fs::write(
&yaml_path,
"\u{feff}project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
)
.expect("yaml should be written with BOM");
let healed = super::heal_config_file(&yaml_path, "yaml").expect("heal should succeed");
assert!(healed, "BOM config should be rewritten");
let rewritten = fs::read_to_string(&yaml_path).expect("healed yaml should be readable");
assert!(
!rewritten.starts_with('\u{feff}'),
"rewritten file must not start with BOM"
);
assert!(rewritten.contains("project_name: demo"));
let (config, second_heal) =
parse_config_with_auto_heal::<XbpConfig>(&rewritten, "yaml").expect("reparse");
assert_eq!(config.project_name, "demo");
assert!(second_heal.is_none());
fs::remove_dir_all(project_root).expect("temp project should be removed");
}
#[test]
fn heals_nested_yaml_environment_blocks() {
let yaml = r#"
project_name: demo
port: 3000
build_dir: $HOME/demo
environment:
production:
DATABASE_URL: postgres://localhost/demo
LOG_LEVEL: info
services:
- name: api
target: rust
branch: main
port: 3001
environment:
production:
SERVICE_TOKEN: abc123
"#;
let (config, healed_content) =
parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
let environment = config.environment.expect("top-level env should exist");
assert_eq!(
environment.get("DATABASE_URL"),
Some(&"postgres://localhost/demo".to_string())
);
assert_eq!(environment.get("LOG_LEVEL"), Some(&"info".to_string()));
let service_environment = config.services.expect("services should exist")[0]
.environment
.clone()
.expect("service env should exist");
assert_eq!(
service_environment.get("SERVICE_TOKEN"),
Some(&"abc123".to_string())
);
assert!(healed_content.is_some());
}
#[test]
fn detect_xbp_config_heal_opportunities_finds_systemd_shorthand() {
let yaml = r#"
services:
- name: api
systemd: athena.service
systemd_service_name: athena
"#;
let fixes = detect_xbp_config_heal_opportunities(yaml);
assert_eq!(fixes.len(), 1);
assert!(fixes[0].contains("athena.service"));
}
#[test]
fn heals_systemd_string_shorthand_in_yaml() {
let yaml = r#"
project_name: athena
port: 3000
build_dir: $HOME/athena
systemd: athena.service
services:
- name: api
target: rust
branch: main
port: 3001
systemd: athena-api.service
"#;
let (config, healed_content) =
parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
assert_eq!(
config.systemd_service_name.as_deref(),
Some("athena.service")
);
assert!(config.systemd.is_none());
let service = &config.services.expect("services should exist")[0];
assert_eq!(
service.systemd_service_name.as_deref(),
Some("athena-api.service")
);
assert!(service.systemd.is_none());
assert!(healed_content.is_some());
assert!(!healed_content
.expect("healed content should exist")
.contains("systemd: athena"));
}
#[test]
fn heals_systemd_string_shorthand_without_overwriting_existing_service_name() {
let yaml = r#"
project_name: athena
port: 4052
build_dir: $HOME/athena
services:
- name: athena-gateway
target: rust
branch: main
port: 4052
systemd: athena.service
systemd_service_name: athena
"#;
let (config, healed_content) =
parse_config_with_auto_heal::<XbpConfig>(yaml, "yaml").expect("config should heal");
let service = &config.services.expect("services should exist")[0];
assert_eq!(service.systemd_service_name.as_deref(), Some("athena"));
assert!(service.systemd.is_none());
assert!(healed_content.is_some());
}
#[test]
fn heals_non_string_environment_values_in_json() {
let json = r#"{
"project_name": "demo",
"port": 3000,
"build_dir": "$HOME/demo",
"environment": {
"PORT": 3000,
"DEBUG": true,
"EMPTY": null
}
}"#;
let (config, healed_content) =
parse_config_with_auto_heal::<XbpConfig>(json, "json").expect("config should heal");
let environment = config.environment.expect("top-level env should exist");
assert_eq!(environment.get("PORT"), Some(&"3000".to_string()));
assert_eq!(environment.get("DEBUG"), Some(&"true".to_string()));
assert_eq!(environment.get("EMPTY"), Some(&String::new()));
assert!(healed_content.is_some());
}
#[test]
fn heals_string_bool_fields_in_toml_services() {
let toml = r#"
project_name = "demo"
port = 3000
build_dir = "./"
[[services]]
name = "app"
target = "rust"
target_freeze = "true"
branch = "main"
port = 3000
force_run_from_root = "false"
versioning = "yes"
release = "0"
"#;
let (config, healed_content) =
parse_config_with_auto_heal::<XbpConfig>(toml, "toml").expect("string bools should heal");
let service = &config.services.expect("services")[0];
assert_eq!(service.target_freeze, Some(true));
assert_eq!(service.force_run_from_root, Some(false));
assert_eq!(service.versioning, Some(true));
assert_eq!(service.release, Some(false));
let healed = healed_content.expect("heal should rewrite quoted bools");
assert!(
healed.contains("target_freeze = true")
|| healed.contains("target_freeze=true"),
"healed TOML should emit unquoted bools: {healed}"
);
assert!(!healed.contains(r#"target_freeze = "true""#));
}
#[test]
fn default_project_config_kind_is_toml() {
assert_eq!(super::DEFAULT_PROJECT_CONFIG_KIND, "toml");
assert_eq!(super::DEFAULT_PROJECT_CONFIG_RELATIVE, ".xbp/xbp.toml");
let path = default_project_config_path(std::path::Path::new("/tmp/proj"), "toml");
assert!(path.ends_with(".xbp/xbp.toml") || path.ends_with(r".xbp\xbp.toml"));
assert_eq!(
super::project_config_display_path(std::path::Path::new("/tmp/no-config-here")),
".xbp/xbp.toml"
);
}
#[test]
fn command_helpers_respect_path_order() {
let bin_dir = make_temp_path("bin");
fs::create_dir_all(&bin_dir).expect("temp dir should be created");
fs::write(bin_dir.join("python"), b"").expect("python file should be created");
fs::write(bin_dir.join("pip"), b"").expect("pip file should be created");
with_path(std::slice::from_ref(&bin_dir), || {
assert!(command_exists("python"));
assert_eq!(
first_available_command(&["python3", "python"]),
Some("python".to_string())
);
assert_eq!(preferred_python_command(), "python".to_string());
assert_eq!(preferred_pip_command(), "pip".to_string());
});
fs::remove_dir_all(&bin_dir).expect("temp dir should be removed");
}
#[test]
fn resolves_xbp_project_for_nested_paths() {
let project_root = make_temp_path("ownership");
let service_dir = project_root.join("services").join("api");
fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
fs::create_dir_all(&service_dir).expect("service dir should be created");
fs::write(
project_root.join(".xbp").join("xbp.json"),
br#"{"project_name":"demo"}"#,
)
.expect("xbp config should be written");
let known_projects = vec![KnownXbpProject {
root: project_root.clone(),
name: "demo".to_string(),
}];
assert_eq!(
resolve_xbp_project_for_path(&service_dir, &known_projects),
Some("demo".to_string())
);
fs::remove_dir_all(&project_root).expect("temp project should be removed");
}
#[test]
fn ignores_non_xbp_paths_for_project_resolution() {
let project_root = make_temp_path("ownership-miss");
let other_dir = make_temp_path("ownership-other");
fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
fs::create_dir_all(&other_dir).expect("other dir should be created");
fs::write(
project_root.join(".xbp").join("xbp.json"),
br#"{"project_name":"demo"}"#,
)
.expect("xbp config should be written");
let known_projects = vec![KnownXbpProject {
root: project_root.clone(),
name: "demo".to_string(),
}];
assert_eq!(
resolve_xbp_project_for_path(&other_dir, &known_projects),
None
);
fs::remove_dir_all(&project_root).expect("temp project should be removed");
fs::remove_dir_all(&other_dir).expect("temp other dir should be removed");
}
#[test]
fn auto_converts_legacy_json_to_yaml() {
let project_root = make_temp_path("json-to-yaml");
fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
let json_path = project_root.join(".xbp").join("xbp.json");
fs::write(
&json_path,
br#"{"project_name":"demo","port":3000,"build_dir":"$HOME/demo"}"#,
)
.expect("json should be written");
let output = maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, &json_path)
.expect("resolution should succeed")
.expect("existing json path should be returned");
assert_eq!(output, json_path, "should keep the existing JSON path");
assert!(output.exists(), "json config should still exist");
assert!(
!project_root.join(".xbp").join("xbp.yaml").exists(),
"must not force-create YAML from JSON"
);
let json = fs::read_to_string(output).expect("json should be readable");
assert!(json.contains("\"project_name\":\"demo\"") || json.contains("\"project_name\": \"demo\""));
fs::remove_dir_all(project_root).expect("temp project should be removed");
}
#[test]
fn normalizes_environment_quotes_in_yaml_output() {
let yaml = super::normalize_xbp_yaml_environment_quotes(
r#"project_name: demo
port: 3000
build_dir: ./
environment:
API_PATH: /api/auth
WITH_DOUBLE: 'look "here"'
WITH_SINGLE: "can't fail"
services:
- name: api
target: rust
branch: main
port: 3001
environment:
CALLBACK_PATH: /api/auth/callback
CALLBACK_TITLE: "the 'quoted' callback"
"#,
);
assert!(yaml.contains("API_PATH: \"/api/auth\""));
assert!(yaml.contains("WITH_DOUBLE: 'look \"here\"'"));
assert!(yaml.contains("WITH_SINGLE: \"can't fail\""));
assert!(yaml.contains("CALLBACK_PATH: \"/api/auth/callback\""));
assert!(yaml.contains("CALLBACK_TITLE: \"the 'quoted' callback\""));
}
#[test]
fn writes_json_from_yaml_config() {
let project_root = make_temp_path("yaml-to-json");
fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir should be created");
let yaml_path = project_root.join(".xbp").join("xbp.yaml");
let json_out = project_root.join("xbp.json");
fs::write(
&yaml_path,
"project_name: demo\nport: 3000\nbuild_dir: $HOME/demo\n",
)
.expect("yaml should be written");
write_json_config_from_any_xbp_config(&yaml_path, &json_out)
.expect("json should be generated from yaml");
let json = fs::read_to_string(json_out).expect("json should be readable");
assert!(json.contains("\"project_name\": \"demo\""));
fs::remove_dir_all(project_root).expect("temp project should be removed");
}
#[test]
fn parses_github_repo_from_supported_remote_urls() {
assert_eq!(
parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
Some(("xylex-group".to_string(), "xbp".to_string()))
);
assert_eq!(
parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
Some(("xylex-group".to_string(), "xbp".to_string()))
);
assert_eq!(
parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
Some(("xylex-group".to_string(), "xbp".to_string()))
);
assert_eq!(
parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
None
);
assert_eq!(
parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp/extra.git"),
None
);
}
#[test]
fn redacts_credentials_in_remote_urls() {
let redacted = redact_remote_url_credentials("https://token@example.com/xylex-group/xbp");
assert!(redacted.contains("REDACTED"));
assert!(!redacted.contains("token@example.com"));
}
#[test]
fn reads_origin_remote_url_from_git_config_directory() {
let project_root = make_temp_path("git-config-dir");
let git_dir = project_root.join(".git");
fs::create_dir_all(&git_dir).expect("git dir should be created");
fs::write(
git_dir.join("config"),
"[remote \"origin\"]\n\turl = https://github.com/xylex-group/xbp.git\n",
)
.expect("git config should be written");
let remote = git_remote_url_from_metadata(&project_root, "origin")
.expect("git metadata should parse")
.expect("origin remote should exist");
assert_eq!(remote, "https://github.com/xylex-group/xbp.git");
fs::remove_dir_all(project_root).expect("temp project should be removed");
}
#[test]
fn reads_origin_remote_url_from_gitdir_pointer() {
let project_root = make_temp_path("git-config-file");
let nested_git_dir = project_root.join(".git-real");
fs::create_dir_all(&nested_git_dir).expect("gitdir target should be created");
fs::write(project_root.join(".git"), "gitdir: .git-real\n")
.expect("gitdir pointer should be written");
fs::write(
nested_git_dir.join("config"),
"[remote \"origin\"]\n\turl = git@github.com:xylex-group/xbp.git\n",
)
.expect("git config should be written");
let remote = git_remote_url_from_metadata(&project_root, "origin")
.expect("git metadata should parse")
.expect("origin remote should exist");
assert_eq!(remote, "git@github.com:xylex-group/xbp.git");
fs::remove_dir_all(project_root).expect("temp project should be removed");
}
#[test]
fn reads_origin_remote_url_from_ancestor_git_dir() {
let repo_root = make_temp_path("git-ancestor-repo");
let package_root = repo_root.join("packages").join("athena-auth-ui");
fs::create_dir_all(&package_root).expect("package dir should be created");
let git_dir = repo_root.join(".git");
fs::create_dir_all(&git_dir).expect("git dir should be created");
fs::write(
git_dir.join("config"),
"[remote \"origin\"]\n\turl = https://github.com/xylex-group/athena.git\n",
)
.expect("git config should be written");
let remote = git_remote_url_from_metadata(&package_root, "origin")
.expect("git metadata should parse")
.expect("origin remote should exist via ancestor .git");
assert_eq!(remote, "https://github.com/xylex-group/athena.git");
fs::remove_dir_all(repo_root).expect("temp project should be removed");
}
#[test]
fn durable_object_config_round_trips_yaml_json_jsonc_and_toml() {
let value = serde_json::json!({
"project_name": "worker",
"port": 8787,
"build_dir": ".",
"workers": [{
"name": "auth",
"root": "apps/auth",
"durable_objects": {
"bindings": [{"name": "AUTH", "class_name": "AuthContainer", "script_name": "auth", "environment": "production"}],
"migrations": [{"tag": "v1", "new_sqlite_classes": ["AuthContainer"]}],
"namespaces": [{"id": "namespace-id", "name": "auth_AuthContainer", "script": "auth", "class": "AuthContainer", "use_sqlite": true, "use_containers": true}]
},
"containers": [{"class_name": "AuthContainer", "image": "registry.example/auth:latest", "build_context": ".", "instance_type": "standard", "max_instances": 2, "regions": ["WEUR"]}]
}]
});
for kind in ["yaml", "json", "jsonc", "toml"] {
let rendered = render_xbp_config_value(&value, kind).expect("render config");
let (parsed, _) =
parse_config_with_auto_heal::<Value>(&rendered, kind).expect("parse config");
assert_eq!(
parsed["workers"][0]["durable_objects"]["bindings"][0]["class_name"],
"AuthContainer"
);
assert_eq!(
parsed["workers"][0]["durable_objects"]["namespaces"][0]["use_sqlite"],
true
);
assert_eq!(parsed["workers"][0]["containers"][0]["max_instances"], 2);
}
}
}