feat(notfiles): add detect subcommand for stow/chezmoi/notfiles auto-detection
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
This commit is contained in:
@@ -16,6 +16,10 @@ pub struct Cli {
|
||||
#[arg(long, short, global = true)]
|
||||
pub verbose: bool,
|
||||
|
||||
/// Output in JSON format
|
||||
#[arg(long, global = true)]
|
||||
pub json: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
@@ -50,4 +54,31 @@ pub enum Command {
|
||||
/// Specific packages to check (default: all)
|
||||
packages: Vec<String>,
|
||||
},
|
||||
|
||||
/// Validate config without making changes (CI-friendly)
|
||||
Check,
|
||||
|
||||
/// Generate shell completions
|
||||
Completions {
|
||||
/// Shell to generate for
|
||||
#[arg(value_enum)]
|
||||
shell: clap_complete::Shell,
|
||||
},
|
||||
|
||||
/// Show differences between source and target for copy-method packages
|
||||
Diff {
|
||||
/// Specific packages to diff (default: all copy-method packages)
|
||||
packages: Vec<String>,
|
||||
},
|
||||
|
||||
/// Auto-detect existing dotfile managers (stow, chezmoi, etc.)
|
||||
Detect,
|
||||
|
||||
/// Move existing files into a package and replace with symlinks
|
||||
Adopt {
|
||||
/// Package to adopt files into
|
||||
package: String,
|
||||
/// File paths (relative to target dir) to adopt
|
||||
files: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
300
crates/notfiles/src/detect.rs
Normal file
300
crates/notfiles/src/detect.rs
Normal file
@@ -0,0 +1,300 @@
|
||||
//! Auto-detection of existing dotfile managers.
|
||||
//!
|
||||
//! Scans common dotfile repo locations and identifies what's managing them.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
/// A dotfile manager detected on disk.
|
||||
#[derive(Debug)]
|
||||
pub struct DetectedManager {
|
||||
pub kind: ManagerKind,
|
||||
pub root: PathBuf,
|
||||
pub packages: Vec<DetectedPackage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ManagerKind {
|
||||
/// GNU Stow — packages are top-level dirs, files mirror $HOME layout.
|
||||
Stow,
|
||||
/// chezmoi — uses `.chezmoi.*` config and `dot_` prefix convention.
|
||||
Chezmoi,
|
||||
/// Notfiles — has a `notfiles.toml` config file.
|
||||
Notfiles,
|
||||
/// Unknown structure — looks like a dotfile repo but manager unclear.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ManagerKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ManagerKind::Stow => write!(f, "stow"),
|
||||
ManagerKind::Chezmoi => write!(f, "chezmoi"),
|
||||
ManagerKind::Notfiles => write!(f, "notfiles"),
|
||||
ManagerKind::Unknown => write!(f, "unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A package (top-level directory) inside a detected dotfile repo.
|
||||
#[derive(Debug)]
|
||||
pub struct DetectedPackage {
|
||||
pub name: String,
|
||||
/// Files relative to the package directory (mirrors home layout).
|
||||
pub files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Locations to probe relative to `$HOME`.
|
||||
const CANDIDATE_DIRS: &[&str] = &[
|
||||
"dotfiles",
|
||||
".dotfiles",
|
||||
"dot",
|
||||
".dot",
|
||||
"dots",
|
||||
".dots",
|
||||
"config/dotfiles",
|
||||
".config/dotfiles",
|
||||
];
|
||||
|
||||
/// Detect all dotfile managers reachable from `home`.
|
||||
pub fn detect(home: &Path) -> Vec<DetectedManager> {
|
||||
let mut found = Vec::new();
|
||||
|
||||
for rel in CANDIDATE_DIRS {
|
||||
let dir = home.join(rel);
|
||||
if dir.is_dir()
|
||||
&& let Some(m) = probe(&dir)
|
||||
{
|
||||
found.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
// chezmoi source dir at its default non-standard location
|
||||
let chezmoi_src = home.join(".local/share/chezmoi");
|
||||
if chezmoi_src.is_dir() && !found.iter().any(|m| m.root == chezmoi_src) {
|
||||
found.push(DetectedManager {
|
||||
kind: ManagerKind::Chezmoi,
|
||||
packages: enumerate_chezmoi_packages(&chezmoi_src),
|
||||
root: chezmoi_src,
|
||||
});
|
||||
}
|
||||
|
||||
found
|
||||
}
|
||||
|
||||
fn probe(dir: &Path) -> Option<DetectedManager> {
|
||||
let kind = classify(dir);
|
||||
let packages = match kind {
|
||||
ManagerKind::Stow | ManagerKind::Unknown => enumerate_stow_packages(dir),
|
||||
ManagerKind::Chezmoi => enumerate_chezmoi_packages(dir),
|
||||
ManagerKind::Notfiles => enumerate_notfiles_packages(dir),
|
||||
};
|
||||
Some(DetectedManager {
|
||||
kind,
|
||||
root: dir.to_path_buf(),
|
||||
packages,
|
||||
})
|
||||
}
|
||||
|
||||
fn classify(dir: &Path) -> ManagerKind {
|
||||
if dir.join("notfiles.toml").exists() {
|
||||
return ManagerKind::Notfiles;
|
||||
}
|
||||
if dir.join(".chezmoi.toml.tmpl").exists()
|
||||
|| dir.join(".chezmoi.yaml.tmpl").exists()
|
||||
|| dir.join(".chezmoi.json.tmpl").exists()
|
||||
|| dir.join(".chezmoi.toml").exists()
|
||||
|| dir.join(".chezmoi.yaml").exists()
|
||||
{
|
||||
return ManagerKind::Chezmoi;
|
||||
}
|
||||
if dir.join(".stow-local-ignore").exists()
|
||||
|| dir.join(".stowrc").exists()
|
||||
|| has_stow_structure(dir)
|
||||
{
|
||||
return ManagerKind::Stow;
|
||||
}
|
||||
ManagerKind::Unknown
|
||||
}
|
||||
|
||||
/// Heuristic: looks like stow if ≥2 top-level subdirs contain dot-dirs or
|
||||
/// known config paths (`.config/`, `.local/`, `Library/`, etc.).
|
||||
fn has_stow_structure(dir: &Path) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
let mut stow_like = 0usize;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name();
|
||||
let s = name.to_string_lossy();
|
||||
if s.starts_with('.') || matches!(s.as_ref(), "target" | "src") {
|
||||
continue;
|
||||
}
|
||||
if package_looks_like_stow(&path) {
|
||||
stow_like += 1;
|
||||
if stow_like >= 2 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn package_looks_like_stow(pkg_dir: &Path) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(pkg_dir) else {
|
||||
return false;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let s = name.to_string_lossy();
|
||||
if s.starts_with('.') || s == "Library" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn enumerate_stow_packages(root: &Path) -> Vec<DetectedPackage> {
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut pkgs = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') || matches!(name.as_str(), "target" | "src" | "docs" | "tests") {
|
||||
continue;
|
||||
}
|
||||
let files = walk_files(&path, &path);
|
||||
pkgs.push(DetectedPackage { name, files });
|
||||
}
|
||||
pkgs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
pkgs
|
||||
}
|
||||
|
||||
fn enumerate_chezmoi_packages(root: &Path) -> Vec<DetectedPackage> {
|
||||
let files = walk_files(root, root);
|
||||
if files.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![DetectedPackage {
|
||||
name: "(source)".into(),
|
||||
files,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
fn enumerate_notfiles_packages(root: &Path) -> Vec<DetectedPackage> {
|
||||
let toml_path = root.join("notfiles.toml");
|
||||
if let Ok(content) = std::fs::read_to_string(&toml_path)
|
||||
&& let Some(names) = parse_include_list(&content)
|
||||
{
|
||||
return names
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let pkg_dir = root.join(&name);
|
||||
let files = walk_files(&pkg_dir, &pkg_dir);
|
||||
DetectedPackage { name, files }
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
enumerate_stow_packages(root)
|
||||
}
|
||||
|
||||
/// Minimal parse of `include = ["a", "b"]` from TOML.
|
||||
fn parse_include_list(toml: &str) -> Option<Vec<String>> {
|
||||
let line = toml
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("include"))?;
|
||||
let bracket_start = line.find('[')?;
|
||||
let bracket_end = line.find(']')?;
|
||||
let inner = &line[bracket_start + 1..bracket_end];
|
||||
let names: Vec<String> = inner
|
||||
.split(',')
|
||||
.map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if names.is_empty() { None } else { Some(names) }
|
||||
}
|
||||
|
||||
/// Recursively collect file paths relative to `base`.
|
||||
fn walk_files(dir: &Path, base: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return files;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if matches!(name.as_str(), ".git" | ".DS_Store") {
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
files.extend(walk_files(&path, base));
|
||||
} else if path.is_file()
|
||||
&& let Ok(rel) = path.strip_prefix(base)
|
||||
{
|
||||
files.push(rel.to_path_buf());
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
// ── Output ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn print_detected(managers: &[DetectedManager]) {
|
||||
if managers.is_empty() {
|
||||
println!("No dotfile managers detected.");
|
||||
return;
|
||||
}
|
||||
println!("Detected dotfile managers:\n");
|
||||
for m in managers {
|
||||
let pkg_count = m.packages.len();
|
||||
let file_count: usize = m.packages.iter().map(|p| p.files.len()).sum();
|
||||
let pkg_names: Vec<&str> = m.packages.iter().map(|p| p.name.as_str()).collect();
|
||||
println!(" \x1b[1m{}\x1b[0m {}", m.kind, m.root.display());
|
||||
println!(
|
||||
" {} package{}, {} file{}",
|
||||
pkg_count,
|
||||
if pkg_count == 1 { "" } else { "s" },
|
||||
file_count,
|
||||
if file_count == 1 { "" } else { "s" },
|
||||
);
|
||||
if !pkg_names.is_empty() {
|
||||
println!(" packages: {}", pkg_names.join(", "));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_detected_json(managers: &[DetectedManager]) {
|
||||
let items: Vec<_> = managers
|
||||
.iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
"manager": m.kind.to_string(),
|
||||
"root": m.root.to_string_lossy(),
|
||||
"packages": m.packages.iter().map(|p| {
|
||||
json!({
|
||||
"name": p.name,
|
||||
"files": p.files.iter()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&items).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
@@ -1,49 +1,107 @@
|
||||
//! A modern dotfiles manager — pure Rust alternative to GNU Stow.
|
||||
//!
|
||||
//! Symlinks (or copies) files from organized "package" directories
|
||||
//! into a target location (typically `~`). Supports include/exclude
|
||||
//! filtering, platform gates, and multiple output formats.
|
||||
//!
|
||||
//! # Quick start
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use notfiles::{link, LinkOptions};
|
||||
//! use std::path::Path;
|
||||
//!
|
||||
//! let opts = LinkOptions {
|
||||
//! force: false,
|
||||
//! no_backup: false,
|
||||
//! dry_run: false,
|
||||
//! verbose: true,
|
||||
//! };
|
||||
//! let state = link(Path::new("/home/user/dotfiles"), &[], &opts)
|
||||
//! .expect("link failed");
|
||||
//! ```
|
||||
|
||||
pub mod adapters;
|
||||
pub mod cli;
|
||||
pub mod detect;
|
||||
pub mod ignore;
|
||||
pub mod linker;
|
||||
pub mod package;
|
||||
pub mod ports;
|
||||
pub mod status;
|
||||
|
||||
use anyhow::Result;
|
||||
use std::path::Path;
|
||||
|
||||
use notcore::NotfilesError;
|
||||
use notcore::reporter::Reporter;
|
||||
|
||||
pub use adapters::FileStoreImpl;
|
||||
pub use linker::{LinkOptions, State};
|
||||
pub use package::{resolve_packages, resolve_packages_with_store};
|
||||
pub use linker::{LinkOptions, LinkResult, State};
|
||||
pub use package::{
|
||||
discover_packages_filtered, resolve_packages, resolve_packages_filtered_with_store,
|
||||
resolve_packages_with_store,
|
||||
};
|
||||
pub use ports::FileStore;
|
||||
|
||||
pub fn link(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<State> {
|
||||
link_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl)
|
||||
/// Link all (or specified) packages using the real filesystem and
|
||||
/// terminal output.
|
||||
pub fn link(
|
||||
dotfiles_dir: &Path,
|
||||
packages: &[String],
|
||||
opts: &LinkOptions,
|
||||
) -> Result<State, NotfilesError> {
|
||||
let reporter = adapters::TerminalReporter;
|
||||
link_with_store(
|
||||
dotfiles_dir,
|
||||
packages,
|
||||
opts,
|
||||
&adapters::FileStoreImpl,
|
||||
&reporter,
|
||||
)
|
||||
}
|
||||
|
||||
/// Link packages with injectable filesystem and reporter (for testing).
|
||||
pub fn link_with_store(
|
||||
dotfiles_dir: &Path,
|
||||
packages: &[String],
|
||||
opts: &LinkOptions,
|
||||
fs: &dyn FileStore,
|
||||
) -> Result<State> {
|
||||
reporter: &dyn Reporter,
|
||||
) -> Result<State, NotfilesError> {
|
||||
let config = notcore::Config::load(dotfiles_dir)?;
|
||||
config.validate()?;
|
||||
let mut state = linker::State::load(dotfiles_dir, fs)?;
|
||||
let pkgs = resolve_packages_with_store(dotfiles_dir, packages, fs)?;
|
||||
let pkgs = package::resolve_packages_filtered_with_store(dotfiles_dir, packages, &config, fs)?;
|
||||
for pkg in &pkgs {
|
||||
linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts, fs)?;
|
||||
linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts, fs, reporter)?;
|
||||
}
|
||||
state.save(dotfiles_dir, fs)?;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<()> {
|
||||
unlink_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl)
|
||||
/// Remove managed symlinks/copies for all (or specified) packages.
|
||||
pub fn unlink(
|
||||
dotfiles_dir: &Path,
|
||||
packages: &[String],
|
||||
opts: &LinkOptions,
|
||||
) -> Result<(), NotfilesError> {
|
||||
let reporter = adapters::TerminalReporter;
|
||||
unlink_with_store(
|
||||
dotfiles_dir,
|
||||
packages,
|
||||
opts,
|
||||
&adapters::FileStoreImpl,
|
||||
&reporter,
|
||||
)
|
||||
}
|
||||
|
||||
/// Unlink packages with injectable filesystem and reporter.
|
||||
pub fn unlink_with_store(
|
||||
dotfiles_dir: &Path,
|
||||
packages: &[String],
|
||||
opts: &LinkOptions,
|
||||
fs: &dyn FileStore,
|
||||
) -> Result<()> {
|
||||
reporter: &dyn Reporter,
|
||||
) -> Result<(), NotfilesError> {
|
||||
let mut state = linker::State::load(dotfiles_dir, fs)?;
|
||||
let pkgs = if packages.is_empty() {
|
||||
state
|
||||
@@ -57,7 +115,7 @@ pub fn unlink_with_store(
|
||||
packages.to_vec()
|
||||
};
|
||||
for pkg in &pkgs {
|
||||
linker::unlink_package(dotfiles_dir, &mut state, pkg, opts, fs)?;
|
||||
linker::unlink_package(dotfiles_dir, &mut state, pkg, opts, fs, reporter)?;
|
||||
}
|
||||
state.save(dotfiles_dir, fs)?;
|
||||
Ok(())
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use clap::{CommandFactory, Parser};
|
||||
use std::fs;
|
||||
|
||||
use notcore::Config;
|
||||
use notcore::reporter::Reporter;
|
||||
use notfiles::adapters::{JsonReporter, TerminalReporter};
|
||||
use notfiles::cli::{Cli, Command};
|
||||
use notfiles::detect;
|
||||
use notfiles::linker::{LinkOptions, State};
|
||||
use notfiles::package::resolve_packages_with_store;
|
||||
use notfiles::package::{resolve_packages_filtered_with_store, resolve_packages_with_store};
|
||||
use notfiles::{adapters, linker, status};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -16,7 +19,24 @@ fn main() -> Result<()> {
|
||||
let dotfiles_dir = fs::canonicalize(&dotfiles_dir)
|
||||
.with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?;
|
||||
|
||||
let reporter: Box<dyn Reporter> = if cli.json {
|
||||
Box::new(JsonReporter)
|
||||
} else {
|
||||
Box::new(TerminalReporter)
|
||||
};
|
||||
|
||||
match cli.command {
|
||||
Command::Detect => {
|
||||
let home = std::env::var("HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| dotfiles_dir.clone());
|
||||
let managers = detect::detect(&home);
|
||||
if cli.json {
|
||||
detect::print_detected_json(&managers);
|
||||
} else {
|
||||
detect::print_detected(&managers);
|
||||
}
|
||||
}
|
||||
Command::Init => cmd_init(&dotfiles_dir)?,
|
||||
Command::Link {
|
||||
force,
|
||||
@@ -25,8 +45,9 @@ fn main() -> Result<()> {
|
||||
} => {
|
||||
let fs = &adapters::FileStoreImpl;
|
||||
let config = Config::load(&dotfiles_dir)?;
|
||||
config.validate()?;
|
||||
let mut state = State::load(&dotfiles_dir, fs)?;
|
||||
let pkgs = resolve_packages_with_store(&dotfiles_dir, &packages, fs)?;
|
||||
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
|
||||
let opts = LinkOptions {
|
||||
force,
|
||||
no_backup,
|
||||
@@ -38,19 +59,26 @@ fn main() -> Result<()> {
|
||||
println!("\x1b[36m(dry run)\x1b[0m");
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
for pkg in &pkgs {
|
||||
if cli.verbose || cli.dry_run {
|
||||
println!("Linking {pkg}...");
|
||||
}
|
||||
linker::link_package(&dotfiles_dir, &config, &mut state, pkg, &opts, fs)?;
|
||||
let result = linker::link_package(
|
||||
&dotfiles_dir,
|
||||
&config,
|
||||
&mut state,
|
||||
pkg,
|
||||
&opts,
|
||||
fs,
|
||||
&*reporter,
|
||||
)?;
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
if !cli.dry_run {
|
||||
state.save(&dotfiles_dir, fs)?;
|
||||
let count: usize = pkgs
|
||||
.iter()
|
||||
.map(|p| state.entries_for_package(p).len())
|
||||
.sum();
|
||||
let count: usize = results.iter().map(|r| r.linked + r.copied).sum();
|
||||
println!(
|
||||
"\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m",
|
||||
if count == 1 { "" } else { "s" },
|
||||
@@ -95,7 +123,7 @@ fn main() -> Result<()> {
|
||||
if cli.verbose || cli.dry_run {
|
||||
println!("Unlinking {pkg}...");
|
||||
}
|
||||
linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts, fs)?;
|
||||
linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts, fs, &*reporter)?;
|
||||
}
|
||||
|
||||
if !cli.dry_run {
|
||||
@@ -107,18 +135,103 @@ fn main() -> Result<()> {
|
||||
);
|
||||
}
|
||||
}
|
||||
Command::Completions { shell } => {
|
||||
clap_complete::generate(
|
||||
shell,
|
||||
&mut Cli::command(),
|
||||
"notfiles",
|
||||
&mut std::io::stdout(),
|
||||
);
|
||||
}
|
||||
Command::Check => {
|
||||
let fs = &adapters::FileStoreImpl;
|
||||
let config = Config::load(&dotfiles_dir)?;
|
||||
config.validate()?;
|
||||
let all = resolve_packages_filtered_with_store(&dotfiles_dir, &[], &config, fs)?;
|
||||
|
||||
if cli.json {
|
||||
let skipped: Vec<String> = {
|
||||
let all_unfiltered =
|
||||
notfiles::package::resolve_packages_with_store(&dotfiles_dir, &[], fs)?;
|
||||
all_unfiltered
|
||||
.into_iter()
|
||||
.filter(|p| !all.contains(p))
|
||||
.collect()
|
||||
};
|
||||
let obj = serde_json::json!({
|
||||
"valid": true,
|
||||
"packages": all,
|
||||
"skipped": skipped,
|
||||
});
|
||||
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
|
||||
} else {
|
||||
println!("notfiles.toml: \x1b[32mvalid\x1b[0m");
|
||||
println!("Packages: {} ({} total)", all.join(", "), all.len());
|
||||
}
|
||||
}
|
||||
Command::Diff { packages } => {
|
||||
let fs = &adapters::FileStoreImpl;
|
||||
let config = Config::load(&dotfiles_dir)?;
|
||||
config.validate()?;
|
||||
let state = State::load(&dotfiles_dir, fs)?;
|
||||
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
|
||||
|
||||
for pkg in &pkgs {
|
||||
let entries = status::diff_package(&dotfiles_dir, &config, &state, pkg, fs);
|
||||
status::print_diff(pkg, &entries);
|
||||
}
|
||||
}
|
||||
Command::Adopt { package, files } => {
|
||||
let fs = &adapters::FileStoreImpl;
|
||||
let config = Config::load(&dotfiles_dir)?;
|
||||
let mut state = State::load(&dotfiles_dir, fs)?;
|
||||
let opts = LinkOptions {
|
||||
force: false,
|
||||
no_backup: false,
|
||||
dry_run: cli.dry_run,
|
||||
verbose: cli.verbose,
|
||||
};
|
||||
|
||||
if cli.dry_run {
|
||||
println!("\x1b[36m(dry run)\x1b[0m");
|
||||
}
|
||||
|
||||
let result = linker::adopt_files(
|
||||
&dotfiles_dir,
|
||||
&config,
|
||||
&mut state,
|
||||
&package,
|
||||
&files,
|
||||
&opts,
|
||||
fs,
|
||||
&*reporter,
|
||||
)?;
|
||||
|
||||
if !cli.dry_run {
|
||||
println!(
|
||||
"\x1b[32mAdopted {} file{} into {package}.\x1b[0m",
|
||||
result.linked,
|
||||
if result.linked == 1 { "" } else { "s" },
|
||||
);
|
||||
}
|
||||
}
|
||||
Command::Status { packages } => {
|
||||
let fs = &adapters::FileStoreImpl;
|
||||
let config = Config::load(&dotfiles_dir)?;
|
||||
config.validate()?;
|
||||
let state = State::load(&dotfiles_dir, fs)?;
|
||||
let pkgs = resolve_packages_with_store(&dotfiles_dir, &packages, fs)?;
|
||||
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
|
||||
|
||||
for pkg in &pkgs {
|
||||
let entries = status::package_status(&dotfiles_dir, &config, &state, pkg, fs);
|
||||
if cli.json {
|
||||
status::print_status_json(pkg, &entries);
|
||||
} else {
|
||||
status::print_status(pkg, &entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user