pub mod version;
pub use version::{fetch_version, increment_version};
use serde::de::DeserializeOwned;
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 fn find_xbp_config_upwards(start_dir: &Path) -> Option<FoundXbpConfig> {
for dir in start_dir.ancestors() {
let candidates: [(PathBuf, &'static str); 6] = [
(dir.join(".xbp").join("xbp.yaml"), "yaml"),
(dir.join(".xbp").join("xbp.yml"), "yaml"),
(dir.join(".xbp").join("xbp.json"), "json"),
(dir.join("xbp.yaml"), "yaml"),
(dir.join("xbp.yml"), "yaml"),
(dir.join("xbp.json"), "json"),
];
for (path, kind) in candidates {
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());
}
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 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 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())
}
pub fn parse_config_with_auto_heal<T: DeserializeOwned>(
content: &str,
kind: &str,
) -> Result<(T, Option<String>), String> {
let mut value = match kind {
"yaml" => {
let yaml_value: serde_yaml::Value =
serde_yaml::from_str(content).map_err(|e| e.to_string())?;
serde_json::to_value(yaml_value).map_err(|e| e.to_string())?
}
"json" => serde_json::from_str::<Value>(content).map_err(|e| e.to_string())?,
_ => return Err(format!("Unsupported config kind: {}", kind)),
};
let healed = auto_heal_xbp_config_value(&mut value);
let parsed = serde_json::from_value::<T>(value.clone()).map_err(|e| e.to_string())?;
let healed_content = if healed {
Some(match kind {
"yaml" => serde_yaml::to_string(&value).map_err(|e| e.to_string())?,
"json" => serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?,
_ => unreachable!(),
})
} else {
None
};
Ok((parsed, healed_content))
}
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)
}
fn auto_heal_xbp_config_value(value: &mut Value) -> bool {
let Some(root) = value.as_object_mut() else {
return false;
};
let mut changed = false;
if let Some(environment) = root.get_mut("environment") {
changed |= normalize_environment_value(environment);
}
if let Some(services) = root.get_mut("services").and_then(Value::as_array_mut) {
for service in services {
if let Some(environment) = service
.as_object_mut()
.and_then(|service| service.get_mut("environment"))
{
changed |= normalize_environment_value(environment);
}
}
}
changed
}
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) => {
target.insert(key.clone(), Value::String(string.clone()));
}
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);
}
}
}
}
#[cfg(test)]
mod tests {
use super::{
command_exists, first_available_command, parse_config_with_auto_heal,
preferred_pip_command, preferred_python_command, resolve_xbp_project_for_path,
KnownXbpProject,
};
use crate::strategies::XbpConfig;
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 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 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 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");
}
}