use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use crate::deps::utility_available;
use crate::spec_load::resolve_under_use_root;
use crate::{ConfigLinkSource, ConfigShell, ConfigSpec, RootSpec};
fn print_config_help() {
print!(
"\
config — emit / link / unlink / apply / deps host configuration from the preferred tree
USAGE:
jan config emit [OPTIONS]
jan config link [OPTIONS]
jan config unlink [OPTIONS]
jan config apply [OPTIONS]
jan config deps [OPTIONS]
jan config --help
SUBCOMMANDS:
emit Concatenate `config.shell` fragments (source from your shell / install)
link Place `config.link` files into $HOME (symlink/copy path sources; write inline bodies)
unlink Remove destinations that still match managed path/inline content
apply Run `config.apply` argv lists (e.g. git config --global …)
deps Report `config.deps` host tools missing from PATH
EMIT OPTIONS:
--shell <sh|bash|zsh> Header dialect (default: zsh)
-o, --output <FILE> Write to FILE instead of stdout
LINK OPTIONS:
--dry-run Print planned links without changing the filesystem
--copy Copy files instead of symlinking
--force Replace an existing destination (default: warn and skip)
UNLINK OPTIONS:
--dry-run Print planned removals without changing the filesystem
APPLY OPTIONS:
--dry-run Print argv lists without running them
DEPS OPTIONS:
--strict Exit 1 if any listed tool is missing (default: exit 0)
DESCRIPTION:
Declare `config:` on any command node (see docs/cli/config.md). Fragments and
link sources are paths under the preferred jan directory (`jan use`).
Interactive shortcuts belong in `aliases:` (`jan alias`), not here.
`config link` does not overwrite an existing path unless `--force` is set;
it prints a warning and skips that destination instead.
`config unlink` only deletes destinations that still match the managed
symlink target or inline body (leaves foreign files alone).
"
);
}
#[derive(Debug, Clone)]
struct CollectedConfig {
chain: Vec<String>,
spec: ConfigSpec,
}
fn collect_configs(spec: &RootSpec) -> Vec<CollectedConfig> {
let mut out = Vec::new();
crate::shell_emit::visit_command_tree(&spec.commands, &[], &mut |chain, node| {
if !node.config.is_empty() {
out.push(CollectedConfig {
chain: chain.to_vec(),
spec: node.config.clone(),
});
}
});
out
}
fn expand_home_dest(dest: &str) -> Result<PathBuf> {
let dest = dest.trim();
if dest.is_empty() {
bail!("empty link destination");
}
let expanded = if let Some(rest) = dest.strip_prefix("~/") {
let home = dirs::home_dir().context("$HOME is not set")?;
home.join(rest)
} else if dest == "~" {
dirs::home_dir().context("$HOME is not set")?
} else {
PathBuf::from(dest)
};
if expanded.is_absolute() {
let home = dirs::home_dir().context("$HOME is not set")?;
let xdg = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute());
let under_home = expanded.starts_with(&home);
let under_xdg = xdg.as_ref().is_some_and(|x| expanded.starts_with(x));
if !under_home && !under_xdg {
bail!(
"refusing link destination outside $HOME (or $XDG_CONFIG_HOME): {}",
expanded.display()
);
}
Ok(expanded)
} else {
bail!("link destination must be absolute or start with ~/ : {dest}");
}
}
fn emit_body(spec: &RootSpec, use_root: &Path, shell: &str) -> Result<String> {
let mut body = crate::shell_emit::generated_shell_header(
"jan config emit",
shell,
"# requires: jan use <DIR>; source this file in your shell",
);
let collected = collect_configs(spec);
if collected.is_empty() {
body.push_str("# (no config.shell fragments in the preferred tree)\n");
return Ok(body);
}
for item in collected {
let Some(shell_frag) = &item.spec.shell else {
continue;
};
let chain = item.chain.join(" ");
body.push('\n');
body.push_str(&format!("# --- from `{chain}` ---\n"));
match shell_frag {
ConfigShell::Inline(text) => {
let t = text.trim_end();
body.push_str(t);
if !t.ends_with('\n') {
body.push('\n');
}
}
ConfigShell::Path(rel) => {
let path = resolve_under_use_root(use_root, rel)?;
let text = fs::read_to_string(&path)
.with_context(|| format!("read config.shell {}", path.display()))?;
body.push_str(&format!("# path: {rel}\n"));
let t = text.trim_end();
body.push_str(t);
if !t.ends_with('\n') {
body.push('\n');
}
}
}
}
Ok(body)
}
fn run_emit(spec: &RootSpec, use_root: &Path, args: &[OsString]) -> Result<i32> {
let mut shell = "zsh".to_string();
let mut output: Option<PathBuf> = None;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_config_help();
return Ok(0);
}
"--shell" => {
i += 1;
let v = args
.get(i)
.ok_or_else(|| anyhow::anyhow!("missing value after `--shell`"))?
.to_string_lossy()
.into_owned();
if !matches!(v.as_str(), "sh" | "bash" | "zsh") {
bail!("--shell must be sh, bash, or zsh");
}
shell = v;
}
"-o" | "--output" => {
i += 1;
let v = args
.get(i)
.ok_or_else(|| anyhow::anyhow!("missing value after `-o`/`--output`"))?;
output = Some(PathBuf::from(v));
}
other if other.starts_with('-') => bail!("unknown config emit flag `{other}`"),
_ => bail!("unexpected config emit argument `{s}`"),
}
i += 1;
}
let body = emit_body(spec, use_root, &shell)?;
if let Some(path) = output {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
}
fs::write(&path, body.as_bytes())
.with_context(|| format!("write {}", path.display()))?;
} else {
print!("{body}");
}
Ok(0)
}
fn dest_exists(dest: &Path) -> bool {
dest.symlink_metadata().is_ok()
}
fn already_linked_to(dest: &Path, src: &Path) -> bool {
let Ok(meta) = dest.symlink_metadata() else {
return false;
};
if !meta.file_type().is_symlink() {
return false;
}
match fs::read_link(dest) {
Ok(target) => target == src,
Err(_) => false,
}
}
fn already_written_content(dest: &Path, body: &str) -> bool {
match fs::read(dest) {
Ok(bytes) => bytes == body.as_bytes(),
Err(_) => false,
}
}
#[derive(Default)]
struct LinkSummary {
created: usize,
already_ok: usize,
skipped: usize,
}
impl LinkSummary {
fn print(&self, verb: &str) {
println!("\n=== {verb} summary ===");
println!("created / updated: {}", self.created);
println!("already ok: {}", self.already_ok);
println!("skipped (exists): {}", self.skipped);
}
}
fn run_link(spec: &RootSpec, use_root: &Path, args: &[OsString]) -> Result<i32> {
let mut dry_run = false;
let mut copy = false;
let mut force = false;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_config_help();
return Ok(0);
}
"--dry-run" => dry_run = true,
"--copy" => copy = true,
"--force" => force = true,
other if other.starts_with('-') => bail!("unknown config link flag `{other}`"),
_ => bail!("unexpected config link argument `{s}`"),
}
i += 1;
}
let collected = collect_configs(spec);
let mut summary = LinkSummary::default();
let mut total_entries = 0usize;
for item in &collected {
for (dest_raw, source) in &item.spec.link {
total_entries += 1;
let dest = expand_home_dest(dest_raw)?;
let chain = item.chain.join(" ");
match source {
ConfigLinkSource::Path(src_rel) => {
let src = resolve_under_use_root(use_root, src_rel)?;
let action = if copy { "copy" } else { "symlink" };
if dest_exists(&dest) {
if !copy && already_linked_to(&dest, &src) {
if dry_run {
println!(
"# dry-run [{chain}] already linked {} -> {}",
src.display(),
dest.display()
);
} else {
println!(
"ok (already linked): {} -> {}",
src.display(),
dest.display()
);
}
summary.already_ok += 1;
continue;
}
if !force {
let action = if copy { "copy" } else { "symlink" };
if dry_run {
println!(
"# dry-run [{chain}] skip (exists): {} (use --force to replace)",
dest.display()
);
} else {
eprintln!(
"warning: destination already exists, skipping {action}: {} (use --force to replace)",
dest.display()
);
}
summary.skipped += 1;
continue;
}
}
if dry_run {
println!(
"# dry-run [{chain}] {action} {} -> {}",
src.display(),
dest.display()
);
summary.created += 1;
continue;
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
if dest_exists(&dest) {
fs::remove_file(&dest)
.or_else(|_| fs::remove_dir_all(&dest))
.with_context(|| format!("remove existing {}", dest.display()))?;
}
if copy {
fs::copy(&src, &dest).with_context(|| {
format!("copy {} -> {}", src.display(), dest.display())
})?;
} else {
std::os::unix::fs::symlink(&src, &dest).with_context(|| {
format!("symlink {} -> {}", src.display(), dest.display())
})?;
}
println!("{action}: {} -> {}", src.display(), dest.display());
summary.created += 1;
}
ConfigLinkSource::Inline(body) => {
if dest_exists(&dest) {
if already_written_content(&dest, body) {
if dry_run {
println!(
"# dry-run [{chain}] already written {}",
dest.display()
);
} else {
println!("ok (already written): {}", dest.display());
}
summary.already_ok += 1;
continue;
}
if !force {
if dry_run {
println!(
"# dry-run [{chain}] skip (exists): {} (use --force to replace)",
dest.display()
);
} else {
eprintln!(
"warning: destination already exists, skipping write: {} (use --force to replace)",
dest.display()
);
}
summary.skipped += 1;
continue;
}
}
if dry_run {
let n_lines = body.lines().count();
println!(
"# dry-run [{chain}] write {n_lines} lines -> {}",
dest.display()
);
summary.created += 1;
continue;
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
if dest_exists(&dest) {
fs::remove_file(&dest)
.or_else(|_| fs::remove_dir_all(&dest))
.with_context(|| format!("remove existing {}", dest.display()))?;
}
fs::write(&dest, body.as_bytes())
.with_context(|| format!("write {}", dest.display()))?;
println!("write: {}", dest.display());
summary.created += 1;
}
}
}
}
if total_entries == 0 {
eprintln!("jan config link: no config.link entries in the preferred tree");
} else {
summary.print(if dry_run { "config link (dry-run)" } else { "config link" });
}
Ok(0)
}
#[derive(Default)]
struct UnlinkSummary {
removed: usize,
missing: usize,
skipped: usize,
}
impl UnlinkSummary {
fn print(&self, dry_run: bool) {
let label = if dry_run {
"config unlink (dry-run)"
} else {
"config unlink"
};
println!("\n=== {label} summary ===");
println!("removed: {}", self.removed);
println!("already absent: {}", self.missing);
println!("skipped (foreign): {}", self.skipped);
}
}
fn run_unlink(spec: &RootSpec, use_root: &Path, args: &[OsString]) -> Result<i32> {
let mut dry_run = false;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_config_help();
return Ok(0);
}
"--dry-run" => dry_run = true,
other if other.starts_with('-') => bail!("unknown config unlink flag `{other}`"),
_ => bail!("unexpected config unlink argument `{s}`"),
}
i += 1;
}
let collected = collect_configs(spec);
let mut summary = UnlinkSummary::default();
let mut total_entries = 0usize;
for item in &collected {
for (dest_raw, source) in &item.spec.link {
total_entries += 1;
let dest = expand_home_dest(dest_raw)?;
let chain = item.chain.join(" ");
if !dest_exists(&dest) {
if dry_run {
println!("# dry-run [{chain}] already absent {}", dest.display());
} else {
println!("ok (absent): {}", dest.display());
}
summary.missing += 1;
continue;
}
let managed = match source {
ConfigLinkSource::Path(src_rel) => {
let src = resolve_under_use_root(use_root, src_rel)?;
already_linked_to(&dest, &src)
}
ConfigLinkSource::Inline(body) => already_written_content(&dest, body),
};
if !managed {
if dry_run {
println!(
"# dry-run [{chain}] skip (foreign): {} (not managed by this tree)",
dest.display()
);
} else {
eprintln!(
"warning: destination exists but does not match managed content, skipping unlink: {}",
dest.display()
);
}
summary.skipped += 1;
continue;
}
if dry_run {
println!("# dry-run [{chain}] remove {}", dest.display());
summary.removed += 1;
continue;
}
fs::remove_file(&dest)
.with_context(|| format!("remove {}", dest.display()))?;
println!("removed: {}", dest.display());
summary.removed += 1;
}
}
if total_entries == 0 {
eprintln!("jan config unlink: no config.link entries in the preferred tree");
} else {
summary.print(dry_run);
}
Ok(0)
}
fn run_apply(spec: &RootSpec, args: &[OsString]) -> Result<i32> {
let mut dry_run = false;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_config_help();
return Ok(0);
}
"--dry-run" => dry_run = true,
other if other.starts_with('-') => bail!("unknown config apply flag `{other}`"),
_ => bail!("unexpected config apply argument `{s}`"),
}
i += 1;
}
let collected = collect_configs(spec);
let mut n = 0usize;
for item in &collected {
let chain = item.chain.join(" ");
for argv in &item.spec.apply {
if argv.is_empty() {
continue;
}
if dry_run {
println!("# dry-run [{chain}] {}", argv.join(" "));
n += 1;
continue;
}
let prog = &argv[0];
let status = Command::new(prog)
.args(&argv[1..])
.status()
.with_context(|| format!("spawn `{}` (from `{chain}`)", argv.join(" ")))?;
if !status.success() {
bail!(
"config.apply failed for `{chain}`: {} (exit {:?})",
argv.join(" "),
status.code()
);
}
println!("ok: {}", argv.join(" "));
n += 1;
}
}
if n == 0 {
eprintln!("jan config apply: no config.apply entries in the preferred tree");
}
Ok(0)
}
fn group_label(chain: &[String], node_about: &str) -> String {
let about = node_about.trim();
if !about.is_empty() {
return first_line(about);
}
if chain.is_empty() {
return "deps".to_string();
}
chain.join(" ")
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or("").trim().to_string()
}
fn format_dep_line(bin: &str, hint: &str) -> String {
let hint = hint.trim();
if hint.is_empty() {
bin.to_string()
} else {
format!("{bin} ({hint})")
}
}
fn run_deps(spec: &RootSpec, args: &[OsString]) -> Result<i32> {
let mut strict = false;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_config_help();
return Ok(0);
}
"--strict" => strict = true,
other if other.starts_with('-') => bail!("unknown config deps flag `{other}`"),
_ => bail!("unexpected config deps argument `{s}`"),
}
i += 1;
}
let mut sections: Vec<(String, Vec<(String, String)>)> = Vec::new();
let mut total = 0usize;
crate::shell_emit::visit_command_tree(&spec.commands, &[], &mut |chain, node| {
if node.config.deps.is_empty() {
return;
}
let label = group_label(chain, &node.about);
let mut missing = Vec::new();
for (bin, hint) in &node.config.deps {
let bin = bin.trim();
if bin.is_empty() {
continue;
}
total += 1;
if !utility_available(bin) {
missing.push((bin.to_string(), hint.clone()));
}
}
if !missing.is_empty() {
sections.push((label, missing));
}
});
if total == 0 {
eprintln!("jan config deps: no config.deps entries in the preferred tree");
return Ok(0);
}
let mut missing_n = 0usize;
if sections.is_empty() {
println!("jan config deps: all {total} listed tool(s) are on PATH");
} else {
for (label, missing) in §ions {
missing_n += missing.len();
println!("\nMissing {label}:");
for (bin, hint) in missing {
println!(" {}", format_dep_line(bin, hint));
}
}
println!(
"\n{missing_n} missing of {total} listed tool(s). Install them or trim `config.deps`."
);
}
if strict && missing_n > 0 {
Ok(1)
} else {
Ok(0)
}
}
pub fn dispatch_config(spec: &RootSpec, use_root: &Path, args: &[OsString]) -> Result<i32> {
if args.is_empty() {
print_config_help();
return Ok(0);
}
let first = args[0].to_string_lossy();
match first.as_ref() {
"--help" | "-h" => {
print_config_help();
Ok(0)
}
"emit" => run_emit(spec, use_root, &args[1..]),
"link" => run_link(spec, use_root, &args[1..]),
"unlink" => run_unlink(spec, use_root, &args[1..]),
"apply" => run_apply(spec, &args[1..]),
"deps" => run_deps(spec, &args[1..]),
other => {
bail!("unknown config subcommand `{other}`; use emit, link, unlink, apply, or deps");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CommandNode, ConfigShell, ConfigSpec};
use std::collections::BTreeMap;
#[test]
fn expand_home_accepts_tilde() {
let home = dirs::home_dir().unwrap();
let p = expand_home_dest("~/.config/jan/x").unwrap();
assert_eq!(p, home.join(".config/jan/x"));
}
#[test]
fn expand_home_rejects_outside() {
assert!(expand_home_dest("/etc/passwd").is_err());
}
#[test]
fn collect_walks_nested_config() {
let mut leaf = CommandNode::default();
leaf.config = ConfigSpec {
shell: Some(ConfigShell::Inline("export A=1\n".into())),
..Default::default()
};
let mut mid = CommandNode::default();
mid.commands.insert("zsh".into(), leaf);
let mut root = RootSpec {
metadata: None,
commands: BTreeMap::new(),
};
root.commands.insert("config".into(), mid);
let c = collect_configs(&root);
assert_eq!(c.len(), 1);
assert_eq!(c[0].chain, vec!["config".to_string(), "zsh".to_string()]);
}
}