use std::env;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use clap::builder::styling::{AnsiColor, Reset, Style};
use crate::Result;
use crate::cli::{Cli, Mode};
use crate::config::Config;
use crate::git;
#[derive(Debug, Clone)]
pub struct CommandSpec {
pub program: std::ffi::OsString,
pub args: Vec<std::ffi::OsString>,
}
#[derive(Debug, Clone)]
pub struct Policy {
pub context_dir: PathBuf,
pub write_dirs: Vec<PathBuf>,
pub command: CommandSpec,
pub mode: Mode,
}
impl Policy {
pub fn from_cli(cli: Cli) -> Result<Self> {
let cwd = env::current_dir()?;
let context_dir = match cli.context_dir {
Some(path) => normalize_path(&cwd, &path),
None => cwd.clone(),
};
let mut write_dirs = vec![context_dir.clone()];
if let Some(real_git_dir) = git::detect_real_git_dir(&context_dir) {
write_dirs.push(real_git_dir);
}
match cli.mode {
Mode::Opencode => {
for path in [
"~/.opencode",
"~/.local/share/opencode",
"~/.cache/opencode",
] {
write_dirs.push(expand_path(path, &cwd));
}
}
Mode::Claude => {
write_dirs.push(expand_path("~/.claude", &cwd));
write_dirs.push(expand_path("~/.claude.json", &cwd));
}
Mode::Codex => write_dirs.push(expand_path("~/.codex", &cwd)),
Mode::Basic => {}
}
write_dirs.push(PathBuf::from("/tmp"));
write_dirs.push(PathBuf::from("/dev/null"));
write_dirs.push(PathBuf::from("/dev/tty"));
let config = Config::load()?;
let command = build_command(
cli.mode,
&cli.command_and_args,
config.preset_args(cli.mode),
)?;
dedup_paths(&mut write_dirs);
Ok(Self {
context_dir,
write_dirs,
command,
mode: cli.mode,
})
}
pub fn render_banner(&self, color: bool) -> String {
let (label, label_off) = paint(AnsiColor::Cyan.on_default().bold(), color);
let home = env::var_os("HOME").map(PathBuf::from);
let mut out = String::new();
let _ = writeln!(
out,
"{label}Context root:{label_off} {}",
display_path(&self.context_dir, home.as_deref())
);
let _ = writeln!(out, "{label}Writable:{label_off}");
for group in group_by_parent(&self.write_dirs) {
let rendered: Vec<String> = group
.iter()
.map(|path| display_path(path, home.as_deref()))
.collect();
if rendered.len() == 1 {
let _ = writeln!(out, " - {}", rendered[0]);
} else {
let _ = writeln!(out, " - [{}]", rendered.join(", "));
}
}
out
}
}
fn display_path(path: &Path, home: Option<&Path>) -> String {
if let Some(home) = home {
if let Ok(rest) = path.strip_prefix(home) {
if rest.as_os_str().is_empty() {
return "~".to_string();
}
return format!("~/{}", rest.display());
}
}
path.display().to_string()
}
fn group_by_parent(paths: &[PathBuf]) -> Vec<Vec<PathBuf>> {
let mut groups: Vec<Vec<PathBuf>> = Vec::new();
for path in paths {
let parent = path.parent();
let groupable = parent.is_some_and(|p| !p.as_os_str().is_empty() && p != Path::new("/"));
let merged = match groups.last_mut() {
Some(last) if groupable && last.first().and_then(|p| p.parent()) == parent => {
last.push(path.clone());
true
}
_ => false,
};
if !merged {
groups.push(vec![path.clone()]);
}
}
groups
}
fn paint(style: Style, color: bool) -> (String, String) {
if color {
(style.render().to_string(), Reset.render().to_string())
} else {
(String::new(), String::new())
}
}
fn build_command(
mode: Mode,
input: &[std::ffi::OsString],
extra: &[String],
) -> Result<CommandSpec> {
let preset = |program: &str| {
let mut args: Vec<std::ffi::OsString> =
extra.iter().map(std::ffi::OsString::from).collect();
args.extend_from_slice(input);
CommandSpec {
program: std::ffi::OsString::from(program),
args,
}
};
Ok(match mode {
Mode::Basic => {
let Some(program) = input.first() else {
return Err("missing command".into());
};
CommandSpec {
program: program.clone(),
args: input[1..].to_vec(),
}
}
Mode::Opencode => preset("opencode"),
Mode::Claude => preset("claude"),
Mode::Codex => preset("codex"),
})
}
fn dedup_paths(paths: &mut Vec<PathBuf>) {
let mut seen = std::collections::BTreeSet::new();
paths.retain(|path| seen.insert(path.clone()));
}
fn normalize_path(cwd: &Path, path: &Path) -> PathBuf {
let expanded = expand_os_path(path, cwd);
expanded.canonicalize().unwrap_or(expanded)
}
fn expand_path(path: &str, cwd: &Path) -> PathBuf {
expand_os_path(Path::new(path), cwd)
}
fn expand_os_path(path: &Path, cwd: &Path) -> PathBuf {
let raw = path.to_string_lossy();
if raw == "~" || raw.starts_with("~/") {
if let Some(home) = env::var_os("HOME") {
let suffix = raw.strip_prefix('~').unwrap_or("");
PathBuf::from(home).join(suffix.trim_start_matches('/'))
} else {
path.to_path_buf()
}
} else if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
}
}