feat(workspace): migrate notfiles into crates/ workspace layout

Move src/ and tests/ into crates/notfiles/ as a proper lib+bin crate.
Internal config/error/paths modules replaced with notcore imports.
All 31 workspace tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Joseph O'Brien
2026-03-31 17:23:16 -04:00
parent 6b0bd3beb6
commit ed11aa9bc3
13 changed files with 137 additions and 283 deletions

65
Cargo.lock generated
View File

@@ -37,9 +37,9 @@ dependencies = [
[[package]]
name = "anstyle"
version = "1.0.13"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
@@ -121,9 +121,9 @@ checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "cc"
version = "1.2.56"
version = "1.2.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -188,9 +188,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.4"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "core-foundation-sys"
@@ -425,15 +425,15 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.91"
version = "0.3.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -453,9 +453,9 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "libredox"
version = "0.1.14"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [
"libc",
]
@@ -478,6 +478,17 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "notcore"
version = "0.1.0"
dependencies = [
"anyhow",
"dirs",
"serde",
"thiserror",
"toml",
]
[[package]]
name = "notfiles"
version = "0.1.0"
@@ -488,12 +499,24 @@ dependencies = [
"clap",
"dirs",
"globset",
"notcore",
"serde",
"tempfile",
"thiserror",
"toml",
]
[[package]]
name = "nothooks"
version = "0.1.0"
[[package]]
name = "notsecrets"
version = "0.1.0"
[[package]]
name = "notstrap"
version = "0.1.0"
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -853,9 +876,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631"
dependencies = [
"cfg-if",
"once_cell",
@@ -866,9 +889,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -876,9 +899,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -889,9 +912,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
version = "0.2.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633"
dependencies = [
"unicode-ident",
]

View File

@@ -2,3 +2,26 @@
name = "notfiles"
version = "0.1.0"
edition = "2024"
description = "A modern dotfiles manager — pure Rust alternative to GNU Stow"
[[bin]]
name = "notfiles"
path = "src/main.rs"
[lib]
name = "notfiles"
path = "src/lib.rs"
[dependencies]
notcore = { workspace = true }
clap = { workspace = true }
globset = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
anyhow = { workspace = true }
dirs = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
assert_fs = { workspace = true }

View File

@@ -1,7 +1,7 @@
use globset::{Glob, GlobSet, GlobSetBuilder};
use std::path::Path;
use crate::error::NotfilesError;
use notcore::NotfilesError;
pub struct IgnoreMatcher {
globset: GlobSet,
@@ -17,6 +17,7 @@ impl IgnoreMatcher {
.map_err(|e| NotfilesError::Other(format!("invalid ignore pattern '{pattern}': {e}")))?;
builder.add(glob);
// Also add a recursive variant so "foo" matches "a/foo" etc.
#[allow(clippy::collapsible_if)]
if !pattern.contains('/') && !pattern.starts_with("**/") {
if let Ok(g) = Glob::new(&format!("**/{pattern}")) {
builder.add(g);

View File

@@ -1 +1,46 @@
// placeholder
pub mod cli;
pub mod ignore;
pub mod linker;
pub mod package;
pub mod status;
use anyhow::Result;
use std::path::Path;
pub use linker::{LinkOptions, State};
pub use package::resolve_packages;
pub fn link(
dotfiles_dir: &Path,
packages: &[String],
opts: &LinkOptions,
) -> Result<State> {
let config = notcore::Config::load(dotfiles_dir)?;
let mut state = State::load(dotfiles_dir)?;
let pkgs = resolve_packages(dotfiles_dir, packages)?;
for pkg in &pkgs {
linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts)?;
}
state.save(dotfiles_dir)?;
Ok(state)
}
pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<()> {
let mut state = State::load(dotfiles_dir)?;
let pkgs = if packages.is_empty() {
state
.entries
.iter()
.map(|e| e.package.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect::<Vec<_>>()
} else {
packages.to_vec()
};
for pkg in &pkgs {
linker::unlink_package(dotfiles_dir, &mut state, pkg, opts)?;
}
state.save(dotfiles_dir)?;
Ok(())
}

View File

@@ -4,10 +4,8 @@ use std::path::{Path, PathBuf};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::config::{Config, Method};
use crate::error::NotfilesError;
use notcore::{Config, Method, NotfilesError, expand_tilde};
use crate::package::collect_files;
use crate::paths::expand_tilde;
const STATE_FILE: &str = ".notfiles-state.toml";
@@ -132,15 +130,13 @@ pub fn link_package(
}
// Create parent directories
if let Some(parent) = target.parent() {
if !parent.exists() {
if opts.dry_run {
if opts.verbose {
println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display());
}
} else {
fs::create_dir_all(parent)?;
if let Some(parent) = target.parent().filter(|p| !p.exists()) {
if opts.dry_run {
if opts.verbose {
println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display());
}
} else {
fs::create_dir_all(parent)?;
}
}

View File

@@ -1,21 +1,12 @@
mod cli;
mod config;
mod error;
mod ignore;
mod linker;
mod package;
mod paths;
mod status;
use std::fs;
use anyhow::{Context, Result};
use clap::Parser;
use std::fs;
use cli::{Cli, Command};
use config::Config;
use linker::{LinkOptions, State};
use package::resolve_packages;
use notfiles::cli::{Cli, Command};
use notfiles::linker::{LinkOptions, State};
use notfiles::package::resolve_packages;
use notfiles::{linker, status};
use notcore::Config;
fn main() -> Result<()> {
let cli = Cli::parse();
@@ -66,6 +57,7 @@ fn main() -> Result<()> {
}
Command::Unlink { packages } => {
let config = Config::load(&dotfiles_dir)?;
let _ = &config; // loaded but not needed for unlink
let mut state = State::load(&dotfiles_dir)?;
let pkgs = if packages.is_empty() {
state
@@ -94,7 +86,6 @@ fn main() -> Result<()> {
println!("\x1b[36m(dry run)\x1b[0m");
}
let _ = &config; // loaded but not needed for unlink
for pkg in &pkgs {
if cli.verbose || cli.dry_run {
println!("Unlinking {pkg}...");
@@ -104,7 +95,11 @@ fn main() -> Result<()> {
if !cli.dry_run {
state.save(&dotfiles_dir)?;
println!("\x1b[32mUnlinked {} package{}.\x1b[0m", pkgs.len(), if pkgs.len() == 1 { "" } else { "s" });
println!(
"\x1b[32mUnlinked {} package{}.\x1b[0m",
pkgs.len(),
if pkgs.len() == 1 { "" } else { "s" }
);
}
}
Command::Status { packages } => {
@@ -128,7 +123,7 @@ fn cmd_init(dotfiles_dir: &std::path::Path) -> Result<()> {
println!("notfiles.toml already exists.");
return Ok(());
}
fs::write(&config_path, config::starter_toml())?;
fs::write(&config_path, notcore::config::starter_toml())?;
println!("Created notfiles.toml");
Ok(())
}

View File

@@ -1,8 +1,7 @@
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::Config;
use crate::error::NotfilesError;
use notcore::{Config, NotfilesError};
use crate::ignore::IgnoreMatcher;
/// Discover available packages (subdirectories of the dotfiles dir).

View File

@@ -1,10 +1,9 @@
use std::fs;
use std::path::{Path, PathBuf};
use crate::config::{Config, Method};
use notcore::{Config, Method, expand_tilde};
use crate::linker::State;
use crate::package::collect_files;
use crate::paths::expand_tilde;
#[derive(Debug, PartialEq)]
pub enum FileStatus {

View File

@@ -1,163 +0,0 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use crate::error::NotfilesError;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub defaults: Defaults,
#[serde(default)]
pub packages: HashMap<String, PackageConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Defaults {
#[serde(default = "default_target")]
pub target: String,
#[serde(default = "default_ignore")]
pub ignore: Vec<String>,
}
impl Default for Defaults {
fn default() -> Self {
Self {
target: default_target(),
ignore: default_ignore(),
}
}
}
fn default_target() -> String {
"~".to_string()
}
fn default_ignore() -> Vec<String> {
vec![
".git".to_string(),
".DS_Store".to_string(),
"README.md".to_string(),
"LICENSE".to_string(),
"notfiles.toml".to_string(),
".notfiles-state.toml".to_string(),
]
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PackageConfig {
#[serde(default)]
pub method: Option<Method>,
pub target: Option<String>,
#[serde(default)]
pub ignore: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Method {
#[default]
Symlink,
Copy,
}
impl std::fmt::Display for Method {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Method::Symlink => write!(f, "symlink"),
Method::Copy => write!(f, "copy"),
}
}
}
impl Config {
pub fn load(dotfiles_dir: &Path) -> Result<Self, NotfilesError> {
let config_path = dotfiles_dir.join("notfiles.toml");
if !config_path.exists() {
return Ok(Config::default());
}
let content = std::fs::read_to_string(&config_path)
.map_err(|e| NotfilesError::Config(format!("reading {}: {e}", config_path.display())))?;
let config: Config = toml::from_str(&content)
.map_err(|e| NotfilesError::Config(format!("parsing {}: {e}", config_path.display())))?;
Ok(config)
}
pub fn method_for(&self, package: &str) -> Method {
self.packages
.get(package)
.and_then(|p| p.method)
.unwrap_or_default()
}
pub fn target_for(&self, package: &str) -> &str {
self.packages
.get(package)
.and_then(|p| p.target.as_deref())
.unwrap_or(&self.defaults.target)
}
pub fn ignore_patterns_for(&self, package: &str) -> Vec<&str> {
let mut patterns: Vec<&str> = self.defaults.ignore.iter().map(|s| s.as_str()).collect();
if let Some(pkg) = self.packages.get(package) {
for p in &pkg.ignore {
patterns.push(p.as_str());
}
}
patterns
}
}
pub fn starter_toml() -> &'static str {
r#"[defaults]
target = "~"
ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml"]
# Per-package overrides (only needed for non-default behavior):
# [packages.ssh]
# method = "copy"
# ignore = ["known_hosts"]
#
# [packages.scripts]
# target = "~/bin"
"#
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.defaults.target, "~");
assert!(config.defaults.ignore.contains(&".git".to_string()));
assert_eq!(config.method_for("anything"), Method::Symlink);
assert_eq!(config.target_for("anything"), "~");
}
#[test]
fn test_parse_config() {
let toml_str = r#"
[defaults]
target = "~"
ignore = [".git"]
[packages.ssh]
method = "copy"
ignore = ["known_hosts"]
[packages.scripts]
target = "~/bin"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.method_for("ssh"), Method::Copy);
assert_eq!(config.method_for("scripts"), Method::Symlink);
assert_eq!(config.target_for("scripts"), "~/bin");
assert_eq!(config.target_for("ssh"), "~");
let ssh_ignores = config.ignore_patterns_for("ssh");
assert!(ssh_ignores.contains(&".git"));
assert!(ssh_ignores.contains(&"known_hosts"));
}
}

View File

@@ -1,25 +0,0 @@
use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum NotfilesError {
#[error("config file error: {0}")]
Config(String),
#[error("package not found: {name}")]
PackageNotFound { name: String },
#[error("conflict at {path}: {reason}")]
Conflict { path: PathBuf, reason: String },
#[error("path error: {0}")]
Path(String),
#[error("state file error: {0}")]
State(String),
#[error("{0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
Other(String),
}

View File

@@ -1,39 +0,0 @@
use std::path::PathBuf;
use crate::error::NotfilesError;
/// Expand a leading `~` to the user's home directory.
pub fn expand_tilde(path: &str) -> Result<PathBuf, NotfilesError> {
if path == "~" {
return dirs::home_dir().ok_or_else(|| NotfilesError::Path("cannot determine home directory".into()));
}
if let Some(rest) = path.strip_prefix("~/") {
let home = dirs::home_dir()
.ok_or_else(|| NotfilesError::Path("cannot determine home directory".into()))?;
return Ok(home.join(rest));
}
Ok(PathBuf::from(path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expand_tilde_home() {
let home = dirs::home_dir().unwrap();
assert_eq!(expand_tilde("~").unwrap(), home);
}
#[test]
fn test_expand_tilde_subpath() {
let home = dirs::home_dir().unwrap();
assert_eq!(expand_tilde("~/foo/bar").unwrap(), home.join("foo/bar"));
}
#[test]
fn test_expand_tilde_absolute() {
assert_eq!(expand_tilde("/usr/bin").unwrap(), PathBuf::from("/usr/bin"));
}
}