use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use lds_core::config::{Config, tilde_expand};
use lds_pack::{CreateOptions, Manifest, PackRules, RestoreOptions, RestoreReport, RuleOverrides};
#[derive(Debug, Parser)]
#[command(name = "lds", about = "Local develop server CLI")]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
#[command(name = "recipe-dir")]
RecipeDir {
#[command(subcommand)]
action: RecipeDirAction,
},
Pack {
#[command(subcommand)]
action: PackAction,
},
}
#[derive(Debug, Subcommand)]
pub enum PackAction {
Create {
#[arg(long)]
root: Option<String>,
#[arg(long, short)]
out: Option<String>,
#[arg(long, default_value_t = lds_pack::DEFAULT_COMPRESSION_LEVEL)]
level: i32,
#[arg(long)]
dry_run: bool,
},
Restore {
archive: String,
#[arg(long)]
into: Option<String>,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
},
Inspect {
archive: String,
#[arg(long)]
files: bool,
},
}
#[derive(Debug, Subcommand)]
pub enum RecipeDirAction {
Add {
path: String,
},
List,
Remove {
path: String,
},
}
pub fn run() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::RecipeDir { action } => handle_recipe_dir(action),
Commands::Pack { action } => handle_pack(action),
}
}
fn default_config_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("HOME directory is not set")?;
Ok(home.join(".config/lds/config.toml"))
}
fn handle_recipe_dir(action: RecipeDirAction) -> Result<()> {
match action {
RecipeDirAction::Add { path } => cmd_add(&path),
RecipeDirAction::List => cmd_list(),
RecipeDirAction::Remove { path } => cmd_remove(&path),
}
}
fn cmd_add(raw: &str) -> Result<()> {
let expanded = tilde_expand(raw).with_context(|| format!("failed to expand path '{raw}'"))?;
let abs = std::path::absolute(&expanded)
.with_context(|| format!("failed to make path absolute: {}", expanded.display()))?;
let config_path = default_config_path()?;
let mut config = Config::load_or_default();
if config.recipes.dirs.contains(&abs) {
eprintln!(
"warn: '{}' is already in recipes.dirs — skipping",
abs.display()
);
return Ok(());
}
config.recipes.dirs.push(abs.clone());
Config::save(&config_path, &config.recipes.dirs)
.with_context(|| format!("failed to save config at {}", config_path.display()))?;
println!("added: {}", abs.display());
Ok(())
}
fn cmd_list() -> Result<()> {
let config = Config::load_or_default();
for dir in &config.recipes.dirs {
println!("{}", dir.display());
}
Ok(())
}
fn handle_pack(action: PackAction) -> Result<()> {
match action {
PackAction::Create {
root,
out,
level,
dry_run,
} => cmd_pack_create(root, out, level, dry_run),
PackAction::Restore {
archive,
into,
force,
dry_run,
} => cmd_pack_restore(&archive, into, force, dry_run),
PackAction::Inspect { archive, files } => cmd_pack_inspect(&archive, files),
}
}
fn cmd_pack_create(
root: Option<String>,
out: Option<String>,
level: i32,
dry_run: bool,
) -> Result<()> {
let root = match root {
Some(raw) => resolve_arg_path(&raw)?,
None => std::env::current_dir().context("failed to read current directory")?,
};
let out = match out {
Some(raw) => resolve_arg_path(&raw)?,
None => default_pack_name(&root),
};
let rules = pack_rules_from_config()?;
let customized = rules.is_customized();
let mut opts = CreateOptions::new(&root, &out, env!("CARGO_PKG_VERSION"));
opts.compression_level = level;
opts.rules = rules;
opts.dry_run = dry_run;
let report = lds_pack::create(&opts).context("failed to create pack")?;
let m = &report.manifest;
if report.dry_run {
println!(
"dry run: {} -> {} (nothing written)",
root.display(),
out.display()
);
println!(
" would pack {} files, {} symlinks, {}",
m.stats.file_count,
m.stats.symlink_count,
human_bytes(m.stats.total_bytes)
);
} else {
println!(
"packed: {} -> {}",
root.display(),
report.out_path.display()
);
println!(
" {} files, {} symlinks, {} -> {}",
m.stats.file_count,
m.stats.symlink_count,
human_bytes(m.stats.total_bytes),
human_bytes(report.compressed_bytes)
);
}
if customized {
println!(" (with [pack] overrides from config.toml)");
}
print_manifest_notes(m);
Ok(())
}
fn pack_rules_from_config() -> Result<PackRules> {
let cfg = Config::load_or_default();
let overrides = RuleOverrides {
secret_globs: cfg.pack.secret_globs.clone(),
cache_dirs: cfg.pack.cache_dirs.clone(),
keep: cfg.pack.keep.clone(),
no_link_report: cfg.pack.no_link_report.clone(),
};
PackRules::new(&overrides).context("invalid [pack] configuration in config.toml")
}
fn cmd_pack_restore(archive: &str, into: Option<String>, force: bool, dry_run: bool) -> Result<()> {
let archive = resolve_arg_path(archive)?;
let dest = match into {
Some(raw) => resolve_arg_path(&raw)?,
None => {
let manifest = lds_pack::inspect(&archive)
.with_context(|| format!("failed to read {}", archive.display()))?;
std::env::current_dir()
.context("failed to read current directory")?
.join(manifest.project_name)
}
};
let opts = RestoreOptions {
archive: archive.clone(),
dest,
force,
dry_run,
};
let report = lds_pack::restore(&opts).context("failed to restore pack")?;
if report.dry_run {
println!(
"dry run: restore {} -> {} (nothing written)",
archive.display(),
report.dest.display()
);
println!(" would write {} entries", report.entries_written);
if report.destination_exists {
println!(
" destination exists: {} file(s) would be replaced, {} would remain untouched{}",
report.would_overwrite.len(),
report.would_remain.len(),
if force { "" } else { " (needs --force)" }
);
}
} else {
println!(
"restored: {} -> {} ({} entries)",
archive.display(),
report.dest.display(),
report.entries_written
);
}
if !report.rewritten_worktrees.is_empty() {
println!(
" {} worktree pointers: {}",
if report.dry_run {
"would rewrite"
} else {
"rewrote"
},
report.rewritten_worktrees.join(", ")
);
}
print_restore_attention(&report);
Ok(())
}
fn cmd_pack_inspect(archive: &str, files: bool) -> Result<()> {
let archive = resolve_arg_path(archive)?;
let m = lds_pack::inspect(&archive)
.with_context(|| format!("failed to read {}", archive.display()))?;
println!("archive: {}", archive.display());
println!(" project {}", m.project_name);
println!(" created {}", m.created_at);
println!(" source {}", m.source_root);
println!(" lds {}", m.lds_version);
println!(" format {}", m.format_version);
println!(
" payload {} files, {} symlinks, {}",
m.stats.file_count,
m.stats.symlink_count,
human_bytes(m.stats.total_bytes)
);
print_manifest_notes(&m);
if files {
println!();
for path in lds_pack::list_payload_paths(&archive).context("failed to list payload")? {
println!("{path}");
}
}
Ok(())
}
fn print_manifest_notes(m: &Manifest) {
if !m.skipped_secret.is_empty() {
println!("\nnot packed — secrets (move these yourself):");
for s in &m.skipped_secret {
println!(" {} ({})", s.path, s.reason);
}
}
if !m.skipped_cache.is_empty() {
println!("\nnot packed — caches (regenerable):");
for c in &m.skipped_cache {
println!(
" {} ({} files, {}) ({})",
c.path,
c.file_count,
human_bytes(c.total_bytes),
c.reason
);
for s in &c.secrets {
println!(" credential inside, dropped with it: {}", s.path);
}
}
}
if !m.skipped_noise.is_empty() {
println!("\nnot packed — OS debris ({}):", m.skipped_noise.len());
for s in &m.skipped_noise {
println!(" {}", s.path);
}
}
if !m.symlinks.is_empty() {
let outside = m.symlinks.iter().filter(|s| s.outside_root).count();
println!(
"\nsymlinks: {} recorded ({outside} pointing outside the project)",
m.symlinks.len()
);
for s in m.symlinks.iter().filter(|s| s.outside_root) {
println!(" {} -> {}", s.path, s.target);
}
}
if !m.no_link_report_applied.is_empty() {
println!("\nlink reports suppressed by [pack] no_link_report:");
for glob in &m.no_link_report_applied {
println!(" {glob}");
}
}
if !m.kept_over_secret.is_empty() {
println!("\npacked despite a secret rule, on a keep rule's say-so:");
for k in &m.kept_over_secret {
println!(
" {} (keep `{}` over secret `{}`)",
k.path, k.keep_pattern, k.secret_pattern
);
}
}
if !m.worktrees.is_empty() {
println!("\nworktrees:");
for w in &m.worktrees {
let state = if w.included {
"packed"
} else {
"beside the project, packed separately"
};
println!(" {} ({state})", w.name);
if !w.included {
println!(" was at {}", w.source_path);
}
}
}
if let Some(origin) = &m.worktree_of {
println!("\nthis pack is a worktree named '{}'", origin.name);
println!(" of the repository at {}", origin.parent_root);
println!(" restore that beside this one and the pair wires itself up");
}
}
fn print_restore_attention(report: &RestoreReport) {
if !report.needs_attention() {
return;
}
println!(
"\n{}:",
if report.dry_run {
"would need attention"
} else {
"needs attention"
}
);
if report.dry_run && report.destination_exists {
if !report.would_overwrite.is_empty() {
println!(
" {} existing file(s) would be replaced:",
report.would_overwrite.len()
);
for p in report.would_overwrite.iter().take(5) {
println!(" {p}");
}
if report.would_overwrite.len() > 5 {
println!(" … and {} more", report.would_overwrite.len() - 5);
}
}
if !report.would_remain.is_empty() {
println!(
" {} existing file(s) the pack does not carry would REMAIN (restore overwrites, it does not wipe):",
report.would_remain.len()
);
for p in report.would_remain.iter().take(5) {
println!(" {p}");
}
if report.would_remain.len() > 5 {
println!(" … and {} more", report.would_remain.len() - 5);
}
}
}
if !report.dangling_symlinks.is_empty() {
println!(
" {} symlink(s) {} dangle here:",
report.dangling_symlinks.len(),
if report.dry_run { "would" } else { "" }
);
for s in &report.dangling_symlinks {
println!(" {} -> {}", s.path, s.target);
}
}
if !report.link_reports_suppressed.is_empty() {
println!(
" note: this pack suppressed link reporting under {} — links there were restored but never checked",
report.link_reports_suppressed.join(", ")
);
}
if !report.missing_worktrees.is_empty() {
println!(
" worktrees registered outside the project root, not found here: {}",
report.missing_worktrees.join(", ")
);
println!(" restore their own packs beside this one and the wiring completes");
}
if !report.conflicting_worktrees.is_empty() {
println!(" worktree wiring stopped — the counterpart's place is occupied:");
for c in &report.conflicting_worktrees {
println!(" {} at {}: {}", c.name, c.path, c.found);
}
println!(" nothing was written there; move the occupant or restore elsewhere");
}
if !report.hard_links_not_created.is_empty() {
println!(
" {} hard link(s) {} created — a hard link names a file rather than carrying one, so what it points at is decided here, not by the pack:",
report.hard_links_not_created.len(),
if report.dry_run {
"would not be"
} else {
"were not"
}
);
for h in &report.hard_links_not_created {
println!(" {} -> {}", h.path, h.target);
println!(" {}", h.command);
}
println!(
" look at each target first; run the command yourself if it is what you expect"
);
}
if let Some(parent) = &report.missing_worktree_parent {
println!(" this pack is a worktree; its repository is not beside it here");
println!(" it was at {parent}; restore that pack and the wiring completes");
}
if !report.secrets_not_carried.is_empty() {
println!(" secrets were not carried; move them yourself:");
for s in &report.secrets_not_carried {
println!(" {}", s.path);
}
}
}
fn resolve_arg_path(raw: &str) -> Result<PathBuf> {
let expanded = tilde_expand(raw).with_context(|| format!("failed to expand path '{raw}'"))?;
std::path::absolute(&expanded)
.with_context(|| format!("failed to make path absolute: {}", expanded.display()))
}
fn default_pack_name(root: &Path) -> PathBuf {
let name = root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "project".to_string());
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
PathBuf::from(format!("{name}-{stamp}.pack"))
}
fn human_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} {}", UNITS[0])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
fn cmd_remove(raw: &str) -> Result<()> {
let expanded = tilde_expand(raw).with_context(|| format!("failed to expand path '{raw}'"))?;
let target = std::path::absolute(&expanded)
.with_context(|| format!("failed to make path absolute: {}", expanded.display()))?;
let config_path = default_config_path()?;
let mut config = Config::load_or_default();
let before = config.recipes.dirs.len();
config.recipes.dirs.retain(|p| p != &target);
if config.recipes.dirs.len() == before {
eprintln!("error: '{}' not found in recipes.dirs", target.display());
std::process::exit(1);
}
Config::save(&config_path, &config.recipes.dirs)
.with_context(|| format!("failed to save config at {}", config_path.display()))?;
println!("removed: {}", target.display());
Ok(())
}