use crate::cli::auto_commit::{commit_paths, print_skip, AutoCommitRequest, AutoCommitResult};
use crate::commands::cli_session::fetch_linear_api_key_from_dashboard;
use crate::commands::linear::{fetch_available_initiatives, LinearInitiativeSummary};
use crate::commands::ssh_helpers::prompt_for_password;
use crate::config::{
get_cloudflare_account_id, global_xbp_paths, resolve_linear_api_key, set_cloudflare_account_id,
SecretMetadata, SecretProvider, SshConfig,
};
use crate::logging::{get_prefix, log_info};
use crate::strategies::project_detector::{
infer_project_name as shared_infer_project_name, infer_target as shared_infer_target,
DeploymentRecommendations,
};
use crate::strategies::{ProjectDetector, ProjectType, PublishTargetConfig, XbpConfig};
use crate::utils::{
collapse_project_path, default_project_yaml_config_path, find_xbp_config_upwards,
maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal, FoundXbpConfig,
};
use crate::utils::{open_path_with_editor, open_with_default_handler};
use chrono::Utc;
use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input};
use serde_json::{Map, Value};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, error, info};
const GITHUB_CLASSIC_PAT_URL: &str = "https://github.com/settings/tokens";
const DEFAULT_LINEAR_ORGANIZATION_NAME_PLACEHOLDER: &str = "${LINEAR_ORG_NAME}";
pub async fn run_config(debug: bool) -> Result<(), String> {
let current_dir: PathBuf =
env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
let found = find_xbp_config_upwards(¤t_dir);
let project_root = found
.as_ref()
.map(|f| f.project_root.clone())
.unwrap_or_else(|| current_dir.clone());
let xbp_yaml_path_dotfolder: PathBuf = project_root.join(".xbp/xbp.yaml");
let xbp_yml_path_dotfolder: PathBuf = project_root.join(".xbp/xbp.yml");
let xbp_json_path_dotfolder: PathBuf = project_root.join(".xbp/xbp.json");
let xbp_yaml_path_root: PathBuf = project_root.join("xbp.yaml");
let xbp_yml_path_root: PathBuf = project_root.join("xbp.yml");
let xbp_json_path_root: PathBuf = project_root.join("xbp.json");
if debug {
debug!("Current dir: {}", current_dir.display());
debug!("Project root: {}", project_root.display());
debug!("Checking for: {}", xbp_yaml_path_dotfolder.display());
debug!("Checking for: {}", xbp_yml_path_dotfolder.display());
debug!("Checking for: {}", xbp_json_path_dotfolder.display());
debug!("Checking for: {}", xbp_yaml_path_root.display());
debug!("Checking for: {}", xbp_yml_path_root.display());
debug!("Checking for: {}", xbp_json_path_root.display());
}
let (mut found_path, mut found_location, mut kind): (Option<PathBuf>, Option<String>, &str) =
if let Some(f) = &found {
(
Some(f.config_path.clone()),
Some(f.location.clone()),
f.kind,
)
} else if xbp_yaml_path_dotfolder.exists() {
(
Some(xbp_yaml_path_dotfolder.clone()),
Some(".xbp/xbp.yaml".to_string()),
"yaml",
)
} else if xbp_yml_path_dotfolder.exists() {
(
Some(xbp_yml_path_dotfolder.clone()),
Some(".xbp/xbp.yml".to_string()),
"yaml",
)
} else if xbp_json_path_dotfolder.exists() {
(
Some(xbp_json_path_dotfolder.clone()),
Some(".xbp/xbp.json".to_string()),
"json",
)
} else if xbp_yaml_path_root.exists() {
(
Some(xbp_yaml_path_root.clone()),
Some("xbp.yaml".to_string()),
"yaml",
)
} else if xbp_yml_path_root.exists() {
(
Some(xbp_yml_path_root.clone()),
Some("xbp.yml".to_string()),
"yaml",
)
} else if xbp_json_path_root.exists() {
(
Some(xbp_json_path_root.clone()),
Some("xbp.json".to_string()),
"json",
)
} else {
(None, None, "unknown")
};
if kind == "json" {
if let Some(path) = &found_path {
if let Ok(Some(yaml_path)) =
maybe_auto_convert_legacy_xbp_json_to_yaml(&project_root, path)
{
let yaml_location = yaml_path
.strip_prefix(&project_root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|| yaml_path.to_string_lossy().replace('\\', "/"));
found_path = Some(yaml_path);
found_location = Some(yaml_location);
kind = "yaml";
}
}
}
if found_path.is_none() {
let detected: Option<ProjectType> = ProjectDetector::detect_project_type(¤t_dir)
.await
.ok();
let recommendations: Option<DeploymentRecommendations> =
detected.as_ref().map(|detected| {
ProjectDetector::get_deployment_recommendations(¤t_dir, detected)
});
let default_port: u16 = recommendations
.as_ref()
.map(|r| r.default_port)
.unwrap_or(8080);
let target = detected.as_ref().and_then(shared_infer_target);
let baseline: XbpConfig = XbpConfig {
project_name: match (detected.as_ref(), recommendations.as_ref()) {
(Some(detected), Some(recommendations)) => {
shared_infer_project_name(¤t_dir, detected, recommendations)
}
_ => current_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("app")
.to_string(),
},
version: "0.1.0".to_string(),
port: default_port,
build_dir: collapse_project_path(¤t_dir, current_dir.to_string_lossy().as_ref()),
app_type: target.clone(),
build_command: recommendations
.as_ref()
.and_then(|r| r.build_command.clone()),
start_command: recommendations
.as_ref()
.and_then(|r| r.start_command.clone()),
install_command: recommendations
.as_ref()
.and_then(|r| r.install_command.clone()),
environment: None,
services: None,
systemd_service_name: None,
systemd: None,
kafka_brokers: None,
kafka_topic: None,
kafka_public_url: None,
log_files: None,
monitor_url: None,
monitor_method: None,
monitor_expected_code: None,
monitor_interval: None,
database: None,
target,
branch: Some("main".to_string()),
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
publish: None,
version_targets: Vec::new(),
};
if let Err(e) = fs::create_dir_all(current_dir.join(".xbp")) {
let msg: String = format!("Failed to create .xbp directory: {}", e);
error!("{}", msg);
eprintln!("{}", msg);
} else {
let out_yaml = current_dir.join(".xbp/xbp.yaml");
if let Ok(yaml) = serde_yaml::to_string(&baseline) {
if fs::write(&out_yaml, yaml).is_ok() {
found_path = Some(out_yaml);
found_location = Some(".xbp/xbp.yaml".to_string());
kind = "yaml";
println!("Generated .xbp/xbp.yaml from detected manifests.");
}
}
}
}
if let (Some(path), Some(location)) = (found_path, found_location) {
let _ = log_info(
"config",
&format!("Found config at: {}", path.display()),
None,
)
.await;
println!("Found config at: {}", path.display());
match fs::read_to_string(&path) {
Ok(contents) => {
if debug {
debug!("config contents: {}", contents);
}
let data = if kind == "yaml" {
serde_yaml::from_str::<serde_yaml::Value>(&contents)
.ok()
.and_then(|v| serde_json::to_value(v).ok())
} else {
serde_json::from_str::<serde_json::Value>(&contents).ok()
};
if let Some(json_data) = data {
let prefix = get_prefix();
println!("\nConfiguration:");
println!("{}", "─".repeat(50));
for (key, value) in json_data.as_object().unwrap() {
let value_str: String = value.to_string().replace("\"", "");
println!("{:<15} | {}", key, value_str);
info!("{}{:<15} | {}", prefix, key, value_str);
}
println!("{}", "─".repeat(50));
} else {
let msg = format!("Failed to parse {} contents.", location);
error!("{}", msg);
eprintln!("{}", msg);
}
}
Err(e) => {
let msg = format!("Failed to read {}: {}", location, e);
error!("{}", msg);
eprintln!("{}", msg);
}
}
} else {
let msg =
"No .xbp/xbp.yaml|.yml|.json or xbp.yaml|.yml|.json found in the current directory.";
error!("{}", msg);
eprintln!("{}", msg);
}
Ok(())
}
pub async fn open_global_config(no_open: bool) -> Result<(), String> {
let paths = global_xbp_paths()?;
println!("Global XBP directory: {}", paths.root_dir.display());
println!("Config file: {}", paths.config_file.display());
println!("SSH directory: {}", paths.ssh_dir.display());
println!("Cache directory: {}", paths.cache_dir.display());
println!("Logs directory: {}", paths.logs_dir.display());
if no_open {
return Ok(());
}
let _ = open_with_default_handler(&paths.root_dir.display().to_string());
let _ = open_path_with_editor(&paths.config_file);
Ok(())
}
pub async fn run_config_secret_set(provider_key: &str, key: Option<String>) -> Result<(), String> {
let provider = SecretProvider::from_key(provider_key).ok_or_else(|| {
format!(
"Unsupported provider `{}`. Use `openrouter`, `github`, `cloudflare`, `linear`, `npm`, or `crates`.",
provider_key
)
})?;
let mut config = SshConfig::load()?;
let resolved_key = match key {
Some(value) => value.trim().to_string(),
None => prompt_for_password(&format!("Enter {} key/token: ", provider.as_key()))?,
};
if resolved_key.trim().is_empty() {
if provider == SecretProvider::Github {
let _ = open_with_default_handler(GITHUB_CLASSIC_PAT_URL);
println!(
"No GitHub key/token provided.\nOpened: {}\nCreate a Personal Access Token (classic), copy it, then run `xbp config github set-key` again.",
GITHUB_CLASSIC_PAT_URL
);
return Ok(());
}
return Err("Refusing to store an empty key/token.".to_string());
}
config.set_secret(provider, Some(resolved_key.clone()));
config.save()?;
println!(
"Saved {} in global config field `{}`: {}",
provider.as_key(),
provider.config_field(),
mask_secret(&resolved_key)
);
Ok(())
}
pub async fn run_config_secret_delete(provider_key: &str) -> Result<(), String> {
let provider = SecretProvider::from_key(provider_key).ok_or_else(|| {
format!(
"Unsupported provider `{}`. Use `openrouter`, `github`, `cloudflare`, `linear`, `npm`, or `crates`.",
provider_key
)
})?;
let mut config = SshConfig::load()?;
if config.get_secret(provider).is_none() {
println!("No {} key/token is currently set.", provider.as_key());
return Ok(());
}
config.set_secret(provider, None);
config.save()?;
println!(
"Deleted {} key/token from global config.",
provider.as_key()
);
Ok(())
}
pub async fn run_config_secret_show(provider_key: &str, raw: bool) -> Result<(), String> {
let provider = SecretProvider::from_key(provider_key).ok_or_else(|| {
format!(
"Unsupported provider `{}`. Use `openrouter`, `github`, `cloudflare`, `linear`, `npm`, or `crates`.",
provider_key
)
})?;
let config = SshConfig::load()?;
if let Some(secret) = config.get_secret(provider) {
let display = if raw {
secret.to_string()
} else {
mask_secret(secret)
};
let metadata_suffix = format_secret_metadata(config.get_secret_metadata(provider));
println!(
"{} key/token is set in `{}`: {}{}",
provider.as_key(),
provider.config_field(),
display,
metadata_suffix
);
} else {
println!(
"{} key/token is not configured in `{}`.",
provider.as_key(),
provider.config_field()
);
}
Ok(())
}
pub async fn run_config_cloudflare_account_set(account_id: Option<String>) -> Result<(), String> {
let account_id = match account_id {
Some(value) => value.trim().to_string(),
None => prompt_for_password("Enter Cloudflare account ID: ")?,
};
if account_id.trim().is_empty() {
return Err("Refusing to store an empty Cloudflare account ID.".to_string());
}
set_cloudflare_account_id(Some(account_id.clone()))?;
println!(
"Saved cloudflare account ID in global config field `cloudflare_account_id`: {}",
mask_secret(&account_id)
);
Ok(())
}
pub async fn run_config_cloudflare_account_delete() -> Result<(), String> {
if get_cloudflare_account_id()?.is_none() {
println!("No cloudflare account ID is currently set.");
return Ok(());
}
set_cloudflare_account_id(None)?;
println!("Deleted cloudflare account ID from global config.");
Ok(())
}
pub async fn run_config_cloudflare_account_show(raw: bool) -> Result<(), String> {
if let Some(account_id) = get_cloudflare_account_id()? {
let display = if raw {
account_id
} else {
mask_secret(&account_id)
};
println!(
"cloudflare account ID is set in `cloudflare_account_id`: {}",
display
);
} else {
println!("cloudflare account ID is not configured in `cloudflare_account_id`.");
}
Ok(())
}
pub async fn run_config_linear_select_initiative() -> Result<(), String> {
let current_dir =
env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
let found = find_xbp_config_upwards(¤t_dir).ok_or_else(|| {
"No XBP project config found in the current directory tree. Run this inside a repo with `.xbp/xbp.yaml` or `xbp.yaml`.".to_string()
})?;
let linear_api_key = if let Some(key) = resolve_linear_api_key() {
key
} else if let Some(key) = fetch_linear_api_key_from_dashboard().await? {
key
} else {
return Err(
"No Linear API key found. Configure one with `xbp config linear set-key` or save it in the dashboard settings first.".to_string(),
);
};
let initiatives = fetch_available_initiatives(&linear_api_key).await?;
if initiatives.is_empty() {
println!("No accessible Linear initiatives were found for the configured API key.");
return Ok(());
}
let labels = initiatives
.iter()
.map(format_linear_initiative_label)
.collect::<Vec<_>>();
let selection = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Select a Linear initiative for this repo")
.default(0)
.items(&labels)
.interact_opt()
.map_err(|e| format!("Failed to run initiative picker: {}", e))?;
let Some(selection) = selection else {
println!("No initiative selected. Repo config was not changed.");
return Ok(());
};
let selected = initiatives
.get(selection)
.ok_or_else(|| "Selected initiative index was out of range.".to_string())?;
let saved_path = save_repo_linear_initiative(&found, selected)?;
println!(
"Saved Linear initiative `{}` ({}) to {}",
selected.name,
selected.id,
saved_path.display()
);
match commit_paths(AutoCommitRequest {
project_root: &found.project_root,
paths: vec![saved_path],
message: format!("chore(config): set Linear initiative to {}", selected.id),
action_label: "xbp config linear select-initiative",
})
.await
{
Ok(AutoCommitResult::Committed(_)) => {}
Ok(AutoCommitResult::Skipped(reason)) => {
print_skip("xbp config linear select-initiative", &reason)
}
Err(e) => print_skip("xbp config linear select-initiative", &e),
}
Ok(())
}
pub async fn run_config_publish_setup(provider_key: &str) -> Result<(), String> {
let current_dir =
env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
let found = find_xbp_config_upwards(¤t_dir).ok_or_else(|| {
"No XBP project config found in the current directory tree. Run this inside a repo with `.xbp/xbp.yaml` or `xbp.yaml`.".to_string()
})?;
let (mut config, output_path) = load_project_config_for_repo_linear_update(&found)?;
let defaults =
detect_publish_setup_defaults(&found.project_root, ¤t_dir, provider_key, &config)?;
let package_name: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt(format!("{} package/crate name", provider_key))
.with_initial_text(defaults.package_name)
.interact_text()
.map_err(|e| format!("Failed to read package name: {}", e))?;
let manifest_path: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Manifest path (relative to repo root)")
.with_initial_text(defaults.manifest_path)
.interact_text()
.map_err(|e| format!("Failed to read manifest path: {}", e))?;
let working_directory: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Working directory (relative to repo root)")
.with_initial_text(defaults.working_directory)
.interact_text()
.map_err(|e| format!("Failed to read working directory: {}", e))?;
let token: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Token source (`${...}` placeholder is recommended)")
.with_initial_text(defaults.token_placeholder)
.interact_text()
.map_err(|e| format!("Failed to read token source: {}", e))?;
let preflight: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Preflight commands (comma-separated, leave empty for none)")
.with_initial_text(defaults.preflight_commands.join(", "))
.interact_text()
.map_err(|e| format!("Failed to read preflight commands: {}", e))?;
let publish_command: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Publish command")
.with_initial_text(defaults.publish_command)
.interact_text()
.map_err(|e| format!("Failed to read publish command: {}", e))?;
let use_wsl = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Run preflight/publish commands through WSL?")
.default(defaults.use_wsl)
.interact()
.map_err(|e| format!("Failed to read WSL preference: {}", e))?;
let wsl_distribution = if use_wsl {
let distro: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("WSL distribution (leave empty for default)")
.allow_empty(true)
.interact_text()
.map_err(|e| format!("Failed to read WSL distribution: {}", e))?;
Some(distro.trim().to_string()).filter(|value| !value.is_empty())
} else {
None
};
let access = if provider_key == "npm" {
let npm_access: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("npm access (`public`, `restricted`, or leave empty)")
.allow_empty(true)
.interact_text()
.map_err(|e| format!("Failed to read npm access: {}", e))?;
Some(npm_access.trim().to_string()).filter(|value| !value.is_empty())
} else {
None
};
let publish_config = PublishTargetConfig {
enabled: Some(true),
package_name: Some(package_name.trim().to_string()),
working_directory: Some(working_directory.trim().to_string()),
manifest_path: Some(manifest_path.trim().to_string()),
token: Some(token.trim().to_string()),
preflight_commands: split_csv_commands(&preflight),
publish_command: Some(publish_command.trim().to_string()),
use_wsl: Some(use_wsl),
wsl_distribution,
generate_npmrc: if provider_key == "npm" {
Some(true)
} else {
None
},
access,
};
set_repo_publish_target(&mut config, provider_key, publish_config)?;
write_project_yaml_config(&output_path, &config)?;
println!(
"Saved {} publish config to {}",
provider_key,
output_path.display()
);
match commit_paths(AutoCommitRequest {
project_root: &found.project_root,
paths: vec![output_path.clone()],
message: format!("chore(config): configure {} publish workflow", provider_key),
action_label: "xbp config publish setup",
})
.await
{
Ok(AutoCommitResult::Committed(_)) => {}
Ok(AutoCommitResult::Skipped(reason)) => print_skip("xbp config publish setup", &reason),
Err(e) => print_skip("xbp config publish setup", &e),
}
Ok(())
}
fn mask_secret(secret: &str) -> String {
let chars: Vec<char> = secret.chars().collect();
if chars.is_empty() {
return "(empty)".to_string();
}
if chars.len() <= 8 {
return "*".repeat(chars.len());
}
let prefix: String = chars.iter().take(4).collect();
let suffix: String = chars.iter().skip(chars.len() - 4).collect();
format!("{}...{}", prefix, suffix)
}
fn format_secret_metadata(metadata: Option<&SecretMetadata>) -> String {
let Some(metadata) = metadata else {
return String::new();
};
let Some(added_at) = metadata.added_at else {
return String::new();
};
let age = Utc::now().signed_duration_since(added_at);
let age_label = if age.num_days() >= 1 {
format!("{}d ago", age.num_days())
} else if age.num_hours() >= 1 {
format!("{}h ago", age.num_hours())
} else {
format!("{}m ago", age.num_minutes().max(0))
};
format!(" (added {} / {})", added_at.to_rfc3339(), age_label)
}
fn format_linear_initiative_label(initiative: &LinearInitiativeSummary) -> String {
let mut parts = vec![initiative.name.trim().to_string()];
if let Some(status) = non_empty_text(initiative.status.as_deref()) {
parts.push(format!("[{}]", status));
}
if let Some(owner) = non_empty_text(initiative.owner_name.as_deref()) {
parts.push(owner.to_string());
}
if let Some(target_date) = non_empty_text(initiative.target_date.as_deref()) {
parts.push(target_date.to_string());
}
parts.join(" ")
}
fn save_repo_linear_initiative(
found: &FoundXbpConfig,
initiative: &LinearInitiativeSummary,
) -> Result<PathBuf, String> {
let (mut config, output_path) = load_project_config_for_repo_linear_update(found)?;
set_repo_linear_release_initiative(
&mut config,
initiative.id.clone(),
DEFAULT_LINEAR_ORGANIZATION_NAME_PLACEHOLDER.to_string(),
)?;
write_project_yaml_config(&output_path, &config)?;
Ok(output_path)
}
fn load_project_config_for_repo_linear_update(
found: &FoundXbpConfig,
) -> Result<(Value, PathBuf), String> {
let source_path = if found.kind == "json" {
maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path)?
.unwrap_or_else(|| default_project_yaml_config_path(&found.project_root))
} else {
found.config_path.clone()
};
let kind = if source_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
.unwrap_or(false)
{
"yaml"
} else {
"json"
};
let content = fs::read_to_string(&source_path)
.map_err(|e| format!("Failed to read config {}: {}", source_path.display(), e))?;
let (config, _healed_content) = parse_config_with_auto_heal::<Value>(&content, kind)
.map_err(|e| format!("Failed to parse project config: {}", e))?;
Ok((
config,
default_project_yaml_config_path(&found.project_root),
))
}
fn set_repo_linear_release_initiative(
config: &mut Value,
initiative_id: String,
organization_name: String,
) -> Result<(), String> {
let root = config
.as_object_mut()
.ok_or_else(|| "Project config root must be an object.".to_string())?;
let linear = root
.entry("linear".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if linear.is_null() {
*linear = Value::Object(Map::new());
}
let linear = linear
.as_object_mut()
.ok_or_else(|| "Project config `linear` field must be an object if present.".to_string())?;
let release = linear
.entry("release".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if release.is_null() {
*release = Value::Object(Map::new());
}
let release = release.as_object_mut().ok_or_else(|| {
"Project config `linear.release` field must be an object if present.".to_string()
})?;
release.insert(
"initiative_ids".to_string(),
Value::Array(vec![Value::String(initiative_id)]),
);
release
.entry("organization_name".to_string())
.or_insert_with(|| Value::String(organization_name));
Ok(())
}
fn write_project_yaml_config(path: &Path, config: &Value) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
format!(
"Failed to create config directory {}: {}",
parent.display(),
e
)
})?;
}
let content = serde_yaml::to_string(config)
.map_err(|e| format!("Failed to serialize project config: {}", e))?;
fs::write(path, content)
.map_err(|e| format!("Failed to write project config {}: {}", path.display(), e))
}
fn non_empty_text(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
struct PublishSetupDefaults {
package_name: String,
working_directory: String,
manifest_path: String,
token_placeholder: String,
preflight_commands: Vec<String>,
publish_command: String,
use_wsl: bool,
}
fn detect_publish_setup_defaults(
project_root: &Path,
current_dir: &Path,
provider_key: &str,
config: &Value,
) -> Result<PublishSetupDefaults, String> {
let manifest_name = match provider_key {
"npm" => "package.json",
"crates" => "Cargo.toml",
other => return Err(format!("Unsupported publish setup provider `{}`.", other)),
};
let configured_target = configured_publish_target(config, provider_key);
let manifest_relative = configured_target
.as_ref()
.and_then(|target| non_empty_text(target.manifest_path.as_deref()))
.map(str::to_string)
.or_else(|| {
detect_manifest_from_invocation(project_root, current_dir, manifest_name, provider_key)
})
.or_else(|| {
detect_manifest_from_version_targets(
project_root,
current_dir,
manifest_name,
provider_key,
config,
)
})
.or_else(|| {
let project_manifest = project_root.join(manifest_name);
project_manifest.exists().then(|| {
collapse_project_path(project_root, project_manifest.to_string_lossy().as_ref())
})
})
.unwrap_or_else(|| manifest_name.to_string());
let manifest_path = project_root.join(&manifest_relative);
let working_directory = configured_target
.as_ref()
.and_then(|target| non_empty_text(target.working_directory.as_deref()))
.map(str::to_string)
.or_else(|| {
manifest_path
.parent()
.map(|path| collapse_project_path(project_root, path.to_string_lossy().as_ref()))
})
.unwrap_or_else(|| ".".to_string());
let package_name = configured_target
.as_ref()
.and_then(|target| non_empty_text(target.package_name.as_deref()))
.map(str::to_string)
.or_else(|| match provider_key {
"npm" => read_package_json_name(&manifest_path),
"crates" => read_cargo_package_name(&manifest_path),
_ => None,
})
.unwrap_or_else(|| match provider_key {
"npm" => "package-name".to_string(),
"crates" => "crate-name".to_string(),
_ => "package-name".to_string(),
});
let preflight_commands = configured_target
.as_ref()
.map(|target| {
target
.preflight_commands
.iter()
.map(|command| command.trim().to_string())
.filter(|command| !command.is_empty())
.collect::<Vec<_>>()
})
.filter(|commands| !commands.is_empty())
.unwrap_or_else(|| match provider_key {
"npm" => vec![if manifest_path
.parent()
.map(|dir| dir.join("pnpm-lock.yaml").exists())
.unwrap_or(false)
{
"pnpm test".to_string()
} else {
"npm test".to_string()
}],
"crates" => vec!["cargo test".to_string()],
_ => Vec::new(),
});
let publish_command = configured_target
.as_ref()
.and_then(|target| non_empty_text(target.publish_command.as_deref()))
.map(str::to_string)
.unwrap_or_else(|| match provider_key {
"npm" => "npm publish".to_string(),
"crates" => "cargo publish".to_string(),
_ => "publish".to_string(),
});
let token_placeholder = configured_target
.as_ref()
.and_then(|target| non_empty_text(target.token.as_deref()))
.map(str::to_string)
.unwrap_or_else(|| match provider_key {
"npm" => "${NPM_TOKEN}".to_string(),
"crates" => "${CARGO_REGISTRY_TOKEN}".to_string(),
_ => "${TOKEN}".to_string(),
});
let use_wsl = configured_target
.as_ref()
.and_then(|target| target.use_wsl)
.unwrap_or(false);
Ok(PublishSetupDefaults {
package_name,
working_directory,
manifest_path: manifest_relative,
token_placeholder,
preflight_commands,
publish_command,
use_wsl,
})
}
fn configured_publish_target(config: &Value, provider_key: &str) -> Option<PublishTargetConfig> {
config
.get("publish")?
.get(provider_key)
.cloned()
.and_then(|value| serde_json::from_value(value).ok())
}
fn detect_manifest_from_invocation(
project_root: &Path,
current_dir: &Path,
manifest_name: &str,
provider_key: &str,
) -> Option<String> {
let mut cursor = current_dir.to_path_buf();
loop {
let candidate = cursor.join(manifest_name);
if manifest_supports_provider(&candidate, provider_key) {
return Some(collapse_project_path(
project_root,
candidate.to_string_lossy().as_ref(),
));
}
if cursor == project_root {
break;
}
if !cursor.pop() {
break;
}
if !cursor.starts_with(project_root) {
break;
}
}
None
}
fn detect_manifest_from_version_targets(
project_root: &Path,
current_dir: &Path,
manifest_name: &str,
provider_key: &str,
config: &Value,
) -> Option<String> {
let candidates = config
.get("version_targets")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|target| !target.is_empty())
.filter(|target| target.replace('\\', "/").ends_with(manifest_name))
.map(PathBuf::from)
.map(|path| {
if path.is_absolute() {
path
} else {
project_root.join(path)
}
})
.filter(|path| manifest_supports_provider(path, provider_key))
.collect::<Vec<_>>();
if current_dir != project_root {
let scoped = candidates
.iter()
.filter(|path| {
path.parent()
.map(|parent| current_dir.starts_with(parent))
.unwrap_or(false)
})
.collect::<Vec<_>>();
if scoped.len() == 1 {
return Some(collapse_project_path(
project_root,
scoped[0].to_string_lossy().as_ref(),
));
}
}
if candidates.len() == 1 {
return Some(collapse_project_path(
project_root,
candidates[0].to_string_lossy().as_ref(),
));
}
None
}
fn manifest_supports_provider(path: &Path, provider_key: &str) -> bool {
match provider_key {
"npm" => path.exists(),
"crates" => read_cargo_package_name(path).is_some(),
_ => false,
}
}
fn split_csv_commands(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
fn read_package_json_name(path: &Path) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
let value: Value = serde_json::from_str(&content).ok()?;
value
.get("name")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn read_cargo_package_name(path: &Path) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
let value: toml::Value = toml::from_str(&content).ok()?;
value
.get("package")
.and_then(|value| value.get("name"))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn set_repo_publish_target(
config: &mut Value,
provider_key: &str,
publish_target: PublishTargetConfig,
) -> Result<(), String> {
let root = config
.as_object_mut()
.ok_or_else(|| "Project config root must be an object.".to_string())?;
let publish = root
.entry("publish".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if publish.is_null() {
*publish = Value::Object(Map::new());
}
let publish = publish.as_object_mut().ok_or_else(|| {
"Project config `publish` field must be an object if present.".to_string()
})?;
let rendered = serde_json::to_value(publish_target)
.map_err(|e| format!("Failed to serialize publish config: {}", e))?;
publish.insert(provider_key.to_string(), rendered);
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
detect_publish_setup_defaults, format_linear_initiative_label,
load_project_config_for_repo_linear_update, save_repo_linear_initiative,
set_repo_linear_release_initiative, DEFAULT_LINEAR_ORGANIZATION_NAME_PLACEHOLDER,
};
use crate::commands::linear::LinearInitiativeSummary;
use crate::utils::FoundXbpConfig;
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn creates_linear_release_block_when_missing() {
let mut config = sample_xbp_config();
set_repo_linear_release_initiative(
&mut config,
"initiative-1".to_string(),
DEFAULT_LINEAR_ORGANIZATION_NAME_PLACEHOLDER.to_string(),
)
.expect("set initiative");
let release = config
.get("linear")
.and_then(|linear| linear.get("release"))
.expect("release config");
assert_eq!(
release.get("initiative_ids"),
Some(&json!(["initiative-1"]))
);
assert_eq!(
release.get("organization_name"),
Some(&json!(DEFAULT_LINEAR_ORGANIZATION_NAME_PLACEHOLDER))
);
assert!(release.get("enabled").is_none());
assert!(release.get("health").is_none());
}
#[test]
fn replaces_repo_linear_initiatives_and_preserves_enabled_and_health() {
let mut config = json!({
"project_name": "XBP",
"version": "10.22.0",
"port": 3398,
"build_dir": "./",
"linear": {
"release": {
"enabled": false,
"initiative_ids": ["old-1", "old-2"],
"organization_name": "suits-formations",
"health": "at_risk"
}
}
});
set_repo_linear_release_initiative(
&mut config,
"new-initiative".to_string(),
DEFAULT_LINEAR_ORGANIZATION_NAME_PLACEHOLDER.to_string(),
)
.expect("set initiative");
let release = config
.get("linear")
.and_then(|linear| linear.get("release"))
.expect("release config");
assert_eq!(
release.get("initiative_ids"),
Some(&json!(["new-initiative"]))
);
assert_eq!(
release.get("organization_name"),
Some(&json!("suits-formations"))
);
assert_eq!(release.get("enabled"), Some(&json!(false)));
assert_eq!(release.get("health"), Some(&json!("at_risk")));
}
#[test]
fn formats_initiative_labels_when_optional_fields_are_missing() {
let label = format_linear_initiative_label(&LinearInitiativeSummary {
id: "initiative-1".to_string(),
name: "Formations live".to_string(),
status: Some("Active".to_string()),
health: None,
archived_at: None,
target_date: None,
owner_name: None,
});
assert_eq!(label, "Formations live [Active]");
}
#[test]
fn converts_json_only_repo_config_and_writes_selected_initiative_to_yaml() {
let temp_dir = create_temp_dir("linear-select-json");
let dot_xbp = temp_dir.join(".xbp");
fs::create_dir_all(&dot_xbp).expect("create .xbp");
let json_path = dot_xbp.join("xbp.json");
fs::write(
&json_path,
r#"{
"project_name": "XBP",
"version": "10.22.0",
"port": 3398,
"build_dir": "./",
"linear": {
"release": {
"enabled": true,
"health": "off_track",
"initiative_ids": ["legacy-id"]
}
}
}"#,
)
.expect("write json config");
let found = FoundXbpConfig {
project_root: temp_dir.clone(),
config_path: json_path,
kind: "json",
location: ".xbp/xbp.json".to_string(),
};
let selected = LinearInitiativeSummary {
id: "new-id".to_string(),
name: "Changelog".to_string(),
status: Some("Active".to_string()),
health: Some("onTrack".to_string()),
archived_at: None,
target_date: None,
owner_name: Some("floris".to_string()),
};
let saved_path = save_repo_linear_initiative(&found, &selected).expect("save config");
let rendered = fs::read_to_string(&saved_path).expect("read yaml config");
assert_eq!(saved_path, temp_dir.join(".xbp").join("xbp.yaml"));
assert!(rendered.contains("initiative_ids:"));
assert!(rendered.contains("- new-id"));
assert!(rendered.contains("organization_name: ${LINEAR_ORG_NAME}"));
assert!(rendered.contains("enabled: true"));
assert!(rendered.contains("health: off_track"));
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn loads_existing_yaml_config_for_repo_linear_update() {
let temp_dir = create_temp_dir("linear-select-yaml");
let dot_xbp = temp_dir.join(".xbp");
fs::create_dir_all(&dot_xbp).expect("create .xbp");
let yaml_path = dot_xbp.join("xbp.yaml");
fs::write(
&yaml_path,
r#"project_name: XBP
version: 10.22.0
port: 3398
build_dir: ./
"#,
)
.expect("write yaml config");
let found = FoundXbpConfig {
project_root: temp_dir.clone(),
config_path: yaml_path.clone(),
kind: "yaml",
location: ".xbp/xbp.yaml".to_string(),
};
let (_, output_path) =
load_project_config_for_repo_linear_update(&found).expect("load config");
assert_eq!(output_path, yaml_path);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn publish_setup_defaults_prefer_existing_publish_manifest_path() {
let temp_dir = create_temp_dir("publish-defaults-existing-manifest");
let cli_dir = temp_dir.join("crates").join("cli");
fs::create_dir_all(&cli_dir).expect("create cli dir");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.30.1\"\n",
)
.expect("write cargo");
let config = json!({
"publish": {
"crates": {
"manifest_path": "crates/cli/Cargo.toml",
"working_directory": "crates/cli",
"package_name": "xbp",
"token": "${CARGO_REGISTRY_TOKEN}",
"preflight_commands": ["cargo test"],
"publish_command": "cargo publish",
"use_wsl": true
}
}
});
let defaults = detect_publish_setup_defaults(&temp_dir, &temp_dir, "crates", &config)
.expect("defaults");
assert_eq!(defaults.manifest_path, "crates/cli/Cargo.toml");
assert_eq!(defaults.working_directory, "crates/cli");
assert_eq!(defaults.package_name, "xbp");
assert_eq!(defaults.publish_command, "cargo publish");
assert!(defaults.use_wsl);
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn publish_setup_defaults_use_version_target_when_repo_root_manifest_is_workspace_only() {
let temp_dir = create_temp_dir("publish-defaults-version-target");
let cli_dir = temp_dir.join("crates").join("cli");
fs::create_dir_all(&cli_dir).expect("create cli dir");
fs::write(
temp_dir.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/cli\"]\n",
)
.expect("write workspace cargo");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.30.1\"\n",
)
.expect("write package cargo");
let config = json!({
"version_targets": ["crates/cli/Cargo.toml"]
});
let defaults = detect_publish_setup_defaults(&temp_dir, &temp_dir, "crates", &config)
.expect("defaults");
assert_eq!(defaults.manifest_path, "crates/cli/Cargo.toml");
assert_eq!(defaults.working_directory, "crates/cli");
assert_eq!(defaults.package_name, "xbp");
let _ = fs::remove_dir_all(temp_dir);
}
#[test]
fn saves_initiative_for_repo_config_missing_top_level_port() {
let temp_dir = create_temp_dir("linear-select-missing-port");
let dot_xbp = temp_dir.join(".xbp");
fs::create_dir_all(&dot_xbp).expect("create .xbp");
let yaml_path = dot_xbp.join("xbp.yaml");
fs::write(
&yaml_path,
r#"project_name: XBP
build_dir: ./
services:
- name: web
target: nextjs
branch: main
port: 3000
"#,
)
.expect("write yaml config");
let found = FoundXbpConfig {
project_root: temp_dir.clone(),
config_path: yaml_path,
kind: "yaml",
location: ".xbp/xbp.yaml".to_string(),
};
let selected = LinearInitiativeSummary {
id: "initiative-1".to_string(),
name: "Changelog".to_string(),
status: Some("Active".to_string()),
health: None,
archived_at: None,
target_date: None,
owner_name: None,
};
let saved_path = save_repo_linear_initiative(&found, &selected).expect("save config");
let rendered = fs::read_to_string(saved_path).expect("read yaml config");
assert!(rendered.contains("initiative_ids:"));
assert!(rendered.contains("- initiative-1"));
assert!(rendered.contains("services:"));
let _ = fs::remove_dir_all(temp_dir);
}
fn sample_xbp_config() -> Value {
json!({
"project_name": "XBP",
"version": "10.22.0",
"port": 3398,
"build_dir": "./"
})
}
fn create_temp_dir(name: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let dir = env::temp_dir().join(format!("xbp-{}-{}", name, unique));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
}