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)]
|
#[arg(long, short, global = true)]
|
||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
|
|
||||||
|
/// Output in JSON format
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub json: bool,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Command,
|
pub command: Command,
|
||||||
}
|
}
|
||||||
@@ -50,4 +54,31 @@ pub enum Command {
|
|||||||
/// Specific packages to check (default: all)
|
/// Specific packages to check (default: all)
|
||||||
packages: Vec<String>,
|
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 adapters;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
|
pub mod detect;
|
||||||
pub mod ignore;
|
pub mod ignore;
|
||||||
pub mod linker;
|
pub mod linker;
|
||||||
pub mod package;
|
pub mod package;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use notcore::NotfilesError;
|
||||||
|
use notcore::reporter::Reporter;
|
||||||
|
|
||||||
pub use adapters::FileStoreImpl;
|
pub use adapters::FileStoreImpl;
|
||||||
pub use linker::{LinkOptions, State};
|
pub use linker::{LinkOptions, LinkResult, State};
|
||||||
pub use package::{resolve_packages, resolve_packages_with_store};
|
pub use package::{
|
||||||
|
discover_packages_filtered, resolve_packages, resolve_packages_filtered_with_store,
|
||||||
|
resolve_packages_with_store,
|
||||||
|
};
|
||||||
pub use ports::FileStore;
|
pub use ports::FileStore;
|
||||||
|
|
||||||
pub fn link(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<State> {
|
/// Link all (or specified) packages using the real filesystem and
|
||||||
link_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl)
|
/// 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(
|
pub fn link_with_store(
|
||||||
dotfiles_dir: &Path,
|
dotfiles_dir: &Path,
|
||||||
packages: &[String],
|
packages: &[String],
|
||||||
opts: &LinkOptions,
|
opts: &LinkOptions,
|
||||||
fs: &dyn FileStore,
|
fs: &dyn FileStore,
|
||||||
) -> Result<State> {
|
reporter: &dyn Reporter,
|
||||||
|
) -> Result<State, NotfilesError> {
|
||||||
let config = notcore::Config::load(dotfiles_dir)?;
|
let config = notcore::Config::load(dotfiles_dir)?;
|
||||||
|
config.validate()?;
|
||||||
let mut state = linker::State::load(dotfiles_dir, fs)?;
|
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 {
|
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)?;
|
state.save(dotfiles_dir, fs)?;
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<()> {
|
/// Remove managed symlinks/copies for all (or specified) packages.
|
||||||
unlink_with_store(dotfiles_dir, packages, opts, &adapters::FileStoreImpl)
|
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(
|
pub fn unlink_with_store(
|
||||||
dotfiles_dir: &Path,
|
dotfiles_dir: &Path,
|
||||||
packages: &[String],
|
packages: &[String],
|
||||||
opts: &LinkOptions,
|
opts: &LinkOptions,
|
||||||
fs: &dyn FileStore,
|
fs: &dyn FileStore,
|
||||||
) -> Result<()> {
|
reporter: &dyn Reporter,
|
||||||
|
) -> Result<(), NotfilesError> {
|
||||||
let mut state = linker::State::load(dotfiles_dir, fs)?;
|
let mut state = linker::State::load(dotfiles_dir, fs)?;
|
||||||
let pkgs = if packages.is_empty() {
|
let pkgs = if packages.is_empty() {
|
||||||
state
|
state
|
||||||
@@ -57,7 +115,7 @@ pub fn unlink_with_store(
|
|||||||
packages.to_vec()
|
packages.to_vec()
|
||||||
};
|
};
|
||||||
for pkg in &pkgs {
|
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)?;
|
state.save(dotfiles_dir, fs)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::Parser;
|
use clap::{CommandFactory, Parser};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
use notcore::Config;
|
use notcore::Config;
|
||||||
|
use notcore::reporter::Reporter;
|
||||||
|
use notfiles::adapters::{JsonReporter, TerminalReporter};
|
||||||
use notfiles::cli::{Cli, Command};
|
use notfiles::cli::{Cli, Command};
|
||||||
|
use notfiles::detect;
|
||||||
use notfiles::linker::{LinkOptions, State};
|
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};
|
use notfiles::{adapters, linker, status};
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -16,7 +19,24 @@ fn main() -> Result<()> {
|
|||||||
let dotfiles_dir = fs::canonicalize(&dotfiles_dir)
|
let dotfiles_dir = fs::canonicalize(&dotfiles_dir)
|
||||||
.with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?;
|
.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 {
|
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::Init => cmd_init(&dotfiles_dir)?,
|
||||||
Command::Link {
|
Command::Link {
|
||||||
force,
|
force,
|
||||||
@@ -25,8 +45,9 @@ fn main() -> Result<()> {
|
|||||||
} => {
|
} => {
|
||||||
let fs = &adapters::FileStoreImpl;
|
let fs = &adapters::FileStoreImpl;
|
||||||
let config = Config::load(&dotfiles_dir)?;
|
let config = Config::load(&dotfiles_dir)?;
|
||||||
|
config.validate()?;
|
||||||
let mut state = State::load(&dotfiles_dir, fs)?;
|
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 {
|
let opts = LinkOptions {
|
||||||
force,
|
force,
|
||||||
no_backup,
|
no_backup,
|
||||||
@@ -38,19 +59,26 @@ fn main() -> Result<()> {
|
|||||||
println!("\x1b[36m(dry run)\x1b[0m");
|
println!("\x1b[36m(dry run)\x1b[0m");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
for pkg in &pkgs {
|
for pkg in &pkgs {
|
||||||
if cli.verbose || cli.dry_run {
|
if cli.verbose || cli.dry_run {
|
||||||
println!("Linking {pkg}...");
|
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 {
|
if !cli.dry_run {
|
||||||
state.save(&dotfiles_dir, fs)?;
|
state.save(&dotfiles_dir, fs)?;
|
||||||
let count: usize = pkgs
|
let count: usize = results.iter().map(|r| r.linked + r.copied).sum();
|
||||||
.iter()
|
|
||||||
.map(|p| state.entries_for_package(p).len())
|
|
||||||
.sum();
|
|
||||||
println!(
|
println!(
|
||||||
"\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m",
|
"\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m",
|
||||||
if count == 1 { "" } else { "s" },
|
if count == 1 { "" } else { "s" },
|
||||||
@@ -95,7 +123,7 @@ fn main() -> Result<()> {
|
|||||||
if cli.verbose || cli.dry_run {
|
if cli.verbose || cli.dry_run {
|
||||||
println!("Unlinking {pkg}...");
|
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 {
|
if !cli.dry_run {
|
||||||
@@ -107,15 +135,100 @@ 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 } => {
|
Command::Status { packages } => {
|
||||||
let fs = &adapters::FileStoreImpl;
|
let fs = &adapters::FileStoreImpl;
|
||||||
let config = Config::load(&dotfiles_dir)?;
|
let config = Config::load(&dotfiles_dir)?;
|
||||||
|
config.validate()?;
|
||||||
let state = State::load(&dotfiles_dir, fs)?;
|
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 {
|
for pkg in &pkgs {
|
||||||
let entries = status::package_status(&dotfiles_dir, &config, &state, pkg, fs);
|
let entries = status::package_status(&dotfiles_dir, &config, &state, pkg, fs);
|
||||||
status::print_status(pkg, &entries);
|
if cli.json {
|
||||||
|
status::print_status_json(pkg, &entries);
|
||||||
|
} else {
|
||||||
|
status::print_status(pkg, &entries);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user