feat(notcore): add shared types, config, paths, error crate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
11
crates/notcore/Cargo.toml
Normal file
11
crates/notcore/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "notcore"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
162
crates/notcore/src/config.rs
Normal file
162
crates/notcore/src/config.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::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"]
|
||||
|
||||
# [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"));
|
||||
}
|
||||
}
|
||||
25
crates/notcore/src/error.rs
Normal file
25
crates/notcore/src/error.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
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),
|
||||
}
|
||||
9
crates/notcore/src/lib.rs
Normal file
9
crates/notcore/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod paths;
|
||||
pub mod types;
|
||||
|
||||
pub use config::{Config, Defaults, Method, PackageConfig};
|
||||
pub use error::NotfilesError;
|
||||
pub use paths::{dotfiles_dir, expand_tilde};
|
||||
pub use types::{HookPhase, HookSpec, PackageSpec, Report, Step, StepStatus};
|
||||
42
crates/notcore/src/paths.rs
Normal file
42
crates/notcore/src/paths.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::NotfilesError;
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
pub fn dotfiles_dir() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join("dotfiles"))
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
92
crates/notcore/src/types.rs
Normal file
92
crates/notcore/src/types.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum HookPhase {
|
||||
Dot,
|
||||
Setup,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HookPhase {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
HookPhase::Dot => write!(f, "dot"),
|
||||
HookPhase::Setup => write!(f, "setup"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HookSpec {
|
||||
pub name: String,
|
||||
pub script: String,
|
||||
pub phase: HookPhase,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PackageSpec {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StepStatus {
|
||||
Ok,
|
||||
Skipped,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Step {
|
||||
pub name: String,
|
||||
pub status: StepStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Report {
|
||||
pub steps: Vec<Step>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn add(&mut self, name: impl Into<String>, status: StepStatus) {
|
||||
self.steps.push(Step { name: name.into(), status });
|
||||
}
|
||||
|
||||
pub fn print(&self) {
|
||||
for step in &self.steps {
|
||||
let icon = match &step.status {
|
||||
StepStatus::Ok => "\x1b[32m✓\x1b[0m",
|
||||
StepStatus::Skipped => "\x1b[33m-\x1b[0m",
|
||||
StepStatus::Failed(_) => "\x1b[31m✗\x1b[0m",
|
||||
};
|
||||
let detail = match &step.status {
|
||||
StepStatus::Failed(msg) => format!(" ({msg})"),
|
||||
_ => String::new(),
|
||||
};
|
||||
println!("{icon} {}{detail}", step.name);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_failures(&self) -> bool {
|
||||
self.steps.iter().any(|s| matches!(s.status, StepStatus::Failed(_)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_report_has_failures() {
|
||||
let mut r = Report::default();
|
||||
r.add("step1", StepStatus::Ok);
|
||||
assert!(!r.has_failures());
|
||||
r.add("step2", StepStatus::Failed("oops".into()));
|
||||
assert!(r.has_failures());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_phase_display() {
|
||||
assert_eq!(HookPhase::Dot.to_string(), "dot");
|
||||
assert_eq!(HookPhase::Setup.to_string(), "setup");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user