feat(notfiles): hexagonal architecture refactor with adapters, integration tests, and notsecrets cleanup
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
- Extract FileStore port and InMemoryFileStore/FileStoreImpl/Reporter adapters - Add Reporter trait to notcore with TerminalReporter and JsonReporter - Refactor linker, package, status modules to use FileStore port - Add integration test suite with dotfiles fixtures - Add cross-crate integration tests - Remove ssh_rsa identity/recipient (unsupported) - Add rustqual.toml, .sccignore, notfiles.toml config files - Update CLAUDE.md, README.md, AGENTS.md with current architecture
This commit is contained in:
BIN
.claude/.DS_Store
vendored
Normal file
BIN
.claude/.DS_Store
vendored
Normal file
Binary file not shown.
27
.claude/settings.local.json
Normal file
27
.claude/settings.local.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(doob:*)",
|
||||||
|
"Bash(echo:*)",
|
||||||
|
"Bash(gh:*)",
|
||||||
|
"Bash(op:*)",
|
||||||
|
"Bash(obfsck:*)",
|
||||||
|
"Bash(head:*)",
|
||||||
|
"Bash(git ls-files:*)",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch(domain:www.apache.org)",
|
||||||
|
"Skill(todo:*)",
|
||||||
|
"Bash(PATH=\"/Users/joe/.local/share/mise/shims:$PATH\" which nu)",
|
||||||
|
"Bash(PATH=\"/Users/joe/.local/share/mise/shims:$PATH\" nu --version 2>&1 | head -1)",
|
||||||
|
"Bash(doob todo:*)",
|
||||||
|
"Bash(handoff-detect 2>&1; echo \"exit: $?\")",
|
||||||
|
"Bash(handoff-detect --name && handoff-detect --project)",
|
||||||
|
"Bash(rtk gain:*)",
|
||||||
|
"Bash(rtk rewrite:*)",
|
||||||
|
"Bash(rtk grep:*)",
|
||||||
|
"Bash(nu -c '\nlet result = \\(do { python3 -c \"import json; print\\(json.dumps\\({\\\\\"ok\\\\\": True}\\)\\)\" } | complete\\)\nprint $result.stdout\n')",
|
||||||
|
"Bash(echo '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"grep -n \\\\\"^foo\\\\\" /file.md\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu)",
|
||||||
|
"Bash(echo '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"grep -c pattern file.txt\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu\necho '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"grep -i \\\\\"Hello World\\\\\" /dir/\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu\necho '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"git status\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu; echo \"exit: $?\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
25
.codex/hooks.json
Normal file
25
.codex/hooks.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash -c 'if [ -f Cargo.toml ]; then cargo check --quiet 2>&1 | head -20; fi'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "nu /Users/joe/dev/notfiles/scripts/preflight.nu"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Handoff — notfiles (2026-06-06)
|
# Handoff — notfiles (2026-07-07)
|
||||||
|
|
||||||
**Branch:** main | **Build:** unknown | **Tests:** unknown
|
**Branch:** main | **Build:** unknown | **Tests:** unknown
|
||||||
|
|
||||||
@@ -6,9 +6,11 @@
|
|||||||
|
|
||||||
| ID | P | Status | Title |
|
| ID | P | Status | Title |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| uncommitted-work | P1 | open | Uncommitted changes (3 files) |
|
||||||
|
|
||||||
## Log
|
## Log
|
||||||
|
|
||||||
|
- 20260606.135918: done=13 running=0 pending=0 blocked=0
|
||||||
- 2026-04-17: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete
|
- 2026-04-17: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete
|
||||||
- 2026-04-15: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage
|
- 2026-04-15: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage
|
||||||
- 2026-04-15: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work
|
- 2026-04-15: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
project: notfiles
|
project: notfiles
|
||||||
id: notfile
|
id: notfile
|
||||||
updated: 2026-06-06
|
updated: 2026-07-07
|
||||||
items: []
|
items:
|
||||||
|
- id: uncommitted-work
|
||||||
|
doob_uuid: null
|
||||||
|
name: uncommitted-work
|
||||||
|
priority: P1
|
||||||
|
status: open
|
||||||
|
title: Uncommitted changes (3 files)
|
||||||
|
description: |-
|
||||||
|
Working tree has uncommitted changes:
|
||||||
|
.claude/worktrees/agent-a27fe5f2
|
||||||
|
.claude/settings.local.json
|
||||||
|
.superpowers/brainstorm/72758-1776397235/state/server-stopped
|
||||||
|
files:
|
||||||
|
- .claude/worktrees/agent-a27fe5f2
|
||||||
|
- .claude/settings.local.json
|
||||||
|
- .superpowers/brainstorm/72758-1776397235/state/server-stopped
|
||||||
|
completed: null
|
||||||
|
extra: []
|
||||||
log:
|
log:
|
||||||
|
- date: '20260606.135918'
|
||||||
|
summary: done=13 running=0 pending=0 blocked=0
|
||||||
|
commits: []
|
||||||
- date: 2026-04-17
|
- date: 2026-04-17
|
||||||
summary: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete
|
summary: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete
|
||||||
commits: []
|
commits: []
|
||||||
@@ -12,8 +32,3 @@ log:
|
|||||||
- date: 2026-04-15
|
- date: 2026-04-15
|
||||||
summary: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work
|
summary: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work
|
||||||
commits: []
|
commits: []
|
||||||
branch: main
|
|
||||||
state:
|
|
||||||
tests: passing
|
|
||||||
build: clean
|
|
||||||
notes: Handoff is now ephemeral; track active work via GitHub issues synced through doob.
|
|
||||||
|
|||||||
76
.notfiles-state.toml
Normal file
76
.notfiles-state.toml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/aliases.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/aliases.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728685+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/audit.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/audit.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728734+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/did-you-mean.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/did-you-mean.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728773+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/functions.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/functions.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728809+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/nu_libs.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/nu_libs.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728847+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/nuenv.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/nuenv.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728879+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/session-guard.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/session-guard.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728915+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/settings.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/settings.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728947+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/toolkit-hook.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/toolkit-hook.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728981+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/config.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/config.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.729023+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/env.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/env.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.729055+00:00"
|
||||||
11
.sccignore
Normal file
11
.sccignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.ctx
|
||||||
|
.kgx
|
||||||
|
.github
|
||||||
|
.claude
|
||||||
|
docs
|
||||||
|
examples
|
||||||
|
fuzz
|
||||||
|
target
|
||||||
|
*.md
|
||||||
|
*.json
|
||||||
|
tests/
|
||||||
BIN
.superpowers/brainstorm/72758-1776397235/.DS_Store
vendored
Normal file
BIN
.superpowers/brainstorm/72758-1776397235/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"idle timeout","timestamp":1776399095365}
|
||||||
110
AGENTS.md
Normal file
110
AGENTS.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What is notfiles?
|
||||||
|
|
||||||
|
A modern dotfiles manager written in Rust — a pure Rust alternative to GNU Stow. It symlinks (or copies) files from organized "package" directories into a target location (typically `~`).
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build # build all workspace crates
|
||||||
|
cargo build -p notfiles # build just the notfiles binary
|
||||||
|
cargo test # run all tests across workspace
|
||||||
|
cargo test -p notcore # test notcore only
|
||||||
|
cargo test -p notfiles # test notfiles only
|
||||||
|
cargo test -p notsecrets # test notsecrets only
|
||||||
|
cargo test -p nothooks # test nothooks only
|
||||||
|
cargo clippy --workspace # lint all crates
|
||||||
|
cargo fmt --check # check formatting
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workspace Structure
|
||||||
|
|
||||||
|
This is a Cargo workspace with 7 crates under `crates/`:
|
||||||
|
|
||||||
|
| Crate | Purpose |
|
||||||
|
| ------------ | -------------------------------------------------------------------------- |
|
||||||
|
| `notcore` | Shared types: `Config`, `NotfilesError`, `Reporter`, `LinkEvent`, |
|
||||||
|
| | `expand_tilde`, `suggest_package`, `HookPhase`, `HookSpec`, `Report` |
|
||||||
|
| `notfiles` | Dotfiles linker — lib + `notfiles` binary. Subcommands: `init`, `link`, |
|
||||||
|
| | `unlink`, `status`, `check`, `diff`, `adopt`, `completions` |
|
||||||
|
| `notsecrets` | Multi-provider secret resolution (`SecretResolver`), age encryption/ |
|
||||||
|
| | decryption, identity management |
|
||||||
|
| `nothooks` | Nushell hook runner with dot/setup phases and state persistence |
|
||||||
|
| `notnet` | Network utilities — Tailscale integration, YubikeySource |
|
||||||
|
| `notstrap` | New-machine bootstrap orchestrator — ties all crates together |
|
||||||
|
| `notgraph` | Dependency/import graph for Rust files — HTML/MD/JSON/Mermaid output, |
|
||||||
|
| | heatmap, cycle detection |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### notfiles (primary user-facing tool)
|
||||||
|
|
||||||
|
Eight subcommands: `init`, `link`, `unlink`, `status`, `check`, `diff`,
|
||||||
|
`adopt`, `completions`. Global flags: `--dry-run`, `--verbose`, `--json`.
|
||||||
|
CLI parsing in `crates/notfiles/src/cli.rs`; dispatch in `src/main.rs`.
|
||||||
|
|
||||||
|
**Core flow for `link`:** `main` → `config.validate()` →
|
||||||
|
`resolve_packages_filtered` (include/exclude + platform filtering) →
|
||||||
|
`collect_files` (recursive walk with ignore filtering) →
|
||||||
|
`linker::link_package` (create symlinks or copies, record in state,
|
||||||
|
return `LinkResult` with counters).
|
||||||
|
|
||||||
|
**Architecture**: Hexagonal (ports/adapters). `FileStore` trait abstracts
|
||||||
|
filesystem I/O; `Reporter` trait abstracts output. Adapters:
|
||||||
|
`FileStoreImpl` (real fs), `InMemoryFileStore` (testing),
|
||||||
|
`TerminalReporter` (ANSI), `JsonReporter` (NDJSON).
|
||||||
|
|
||||||
|
Key modules in `crates/notfiles/src/`:
|
||||||
|
|
||||||
|
- **linker** — Creates/removes symlinks or copies via `FileStore` port.
|
||||||
|
Manages `State` (`.notfiles-state.toml`). Returns `LinkResult` with
|
||||||
|
linked/copied/skipped/backed_up counts. Also provides `adopt_files`.
|
||||||
|
- **package** — Discovers packages with include/exclude and platform
|
||||||
|
filtering. Recursively collects files via `IgnoreMatcher`. Typo
|
||||||
|
suggestions via `suggest_package` on not-found errors.
|
||||||
|
- **ignore** — Glob-based ignore matching using `globset`.
|
||||||
|
- **status** — Compares expected vs actual state:
|
||||||
|
linked/copied/missing/conflict/orphan. Also provides `diff_package`
|
||||||
|
for copy-method divergence detection.
|
||||||
|
- **adapters/** — `FileStoreImpl`, `InMemoryFileStore`,
|
||||||
|
`TerminalReporter`, `JsonReporter`.
|
||||||
|
- **ports** — `FileStore` trait definition.
|
||||||
|
|
||||||
|
### notsecrets
|
||||||
|
|
||||||
|
Multi-provider secret resolution via `SecretResolver`. Providers include
|
||||||
|
`EnvSource`, `OpSource` (1Password), `BitwardenSource`, `FileSource`,
|
||||||
|
`DotenvxSource`, `SopsSource`, and others. Native age encryption/decryption
|
||||||
|
with x25519, SSH ed25519/RSA, and scrypt identity support. No external
|
||||||
|
`sops` or `age` binaries required.
|
||||||
|
|
||||||
|
### nothooks
|
||||||
|
|
||||||
|
`HookRunner` executes `.nu` scripts via `nu <script>`. Two phases: `HookPhase::Dot` (always runs) and `HookPhase::Setup` (runs once, tracked in `.nothooks-state.toml`). `--force` flag reruns setup hooks.
|
||||||
|
|
||||||
|
### notstrap
|
||||||
|
|
||||||
|
Orchestrates in order: prereqs check → load config → clone dotfiles → age key → decrypt SOPS → link dotfiles → run hooks → final report. Config file: `notstrap.toml`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`notfiles.toml` lives at the dotfiles directory root. Each subdirectory
|
||||||
|
is a "package".
|
||||||
|
|
||||||
|
Global defaults support `include` (allowlist) or `exclude` (blocklist)
|
||||||
|
for package filtering — mutually exclusive. Per-package config can
|
||||||
|
override: `method` (symlink/copy), `target` directory, additional
|
||||||
|
`ignore` patterns, and `platforms` (e.g. `["linux"]`, `["macos"]`) to
|
||||||
|
gate packages to specific OSes.
|
||||||
|
|
||||||
|
## Edition
|
||||||
|
|
||||||
|
Rust edition 2024. All hook scripts are Nushell (`.nu`) — no `.sh` scripts.
|
||||||
|
|
||||||
|
## CI / Gitea Actions
|
||||||
|
|
||||||
|
Workflows live in `.gitea/workflows/` — mirrors `.github/workflows/` for GitHub.
|
||||||
|
The `public-ready.yml` workflow checks secrets, private IPs, licenses, and tracked secrets files on every push to main.
|
||||||
69
CLAUDE.md
69
CLAUDE.md
@@ -22,36 +22,64 @@ cargo fmt --check # check formatting
|
|||||||
|
|
||||||
## Workspace Structure
|
## Workspace Structure
|
||||||
|
|
||||||
This is a Cargo workspace with 5 crates under `crates/`:
|
This is a Cargo workspace with 7 crates under `crates/`:
|
||||||
|
|
||||||
| Crate | Purpose |
|
| Crate | Purpose |
|
||||||
| ------------ | -------------------------------------------------------------------------------------------------------- |
|
| ------------ | -------------------------------------------------------------------------- |
|
||||||
| `notcore` | Shared types: `Config`, `NotfilesError`, `expand_tilde`, `HookPhase`, `HookSpec`, `Report`, `StepStatus` |
|
| `notcore` | Shared types: `Config`, `NotfilesError`, `Reporter`, `LinkEvent`, |
|
||||||
| `notfiles` | Dotfiles linker — lib + `notfiles` binary (symlink/copy, init, status) |
|
| | `expand_tilde`, `suggest_package`, `HookPhase`, `HookSpec`, `Report` |
|
||||||
| `notsecrets` | Age key retrieval via Bitwarden, file, or prompt; SOPS decryption |
|
| `notfiles` | Dotfiles linker — lib + `notfiles` binary. Subcommands: `init`, `link`, |
|
||||||
| `nothooks` | Nushell hook runner with dot/setup phases and state persistence |
|
| | `unlink`, `status`, `check`, `diff`, `adopt`, `completions` |
|
||||||
| `notstrap` | New-machine bootstrap orchestrator — ties all crates together |
|
| `notsecrets` | Multi-provider secret resolution (`SecretResolver`), age encryption/ |
|
||||||
| `notgraph` | Dependency/import graph for Rust files — HTML/MD/JSON/Mermaid output, heatmap, cycle detection |
|
| | decryption, identity management |
|
||||||
|
| `nothooks` | Nushell hook runner with dot/setup phases and state persistence |
|
||||||
|
| `notnet` | Network utilities — Tailscale integration, YubikeySource |
|
||||||
|
| `notstrap` | New-machine bootstrap orchestrator — ties all crates together |
|
||||||
|
| `notgraph` | Dependency/import graph for Rust files — HTML/MD/JSON/Mermaid output, |
|
||||||
|
| | heatmap, cycle detection |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### notfiles (primary user-facing tool)
|
### notfiles (primary user-facing tool)
|
||||||
|
|
||||||
Four subcommands: `init`, `link`, `unlink`, `status`. CLI parsing in `crates/notfiles/src/cli.rs`; dispatch in `src/main.rs`.
|
Eight subcommands: `init`, `link`, `unlink`, `status`, `check`, `diff`,
|
||||||
|
`adopt`, `completions`. Global flags: `--dry-run`, `--verbose`, `--json`.
|
||||||
|
CLI parsing in `crates/notfiles/src/cli.rs`; dispatch in `src/main.rs`.
|
||||||
|
|
||||||
**Core flow for `link`:** `main` → `resolve_packages` → `collect_files` (recursive walk with ignore filtering) → `linker::link_package` (create symlinks or copies, record in state).
|
**Core flow for `link`:** `main` → `config.validate()` →
|
||||||
|
`resolve_packages_filtered` (include/exclude + platform filtering) →
|
||||||
|
`collect_files` (recursive walk with ignore filtering) →
|
||||||
|
`linker::link_package` (create symlinks or copies, record in state,
|
||||||
|
return `LinkResult` with counters).
|
||||||
|
|
||||||
|
**Architecture**: Hexagonal (ports/adapters). `FileStore` trait abstracts
|
||||||
|
filesystem I/O; `Reporter` trait abstracts output. Adapters:
|
||||||
|
`FileStoreImpl` (real fs), `InMemoryFileStore` (testing),
|
||||||
|
`TerminalReporter` (ANSI), `JsonReporter` (NDJSON).
|
||||||
|
|
||||||
Key modules in `crates/notfiles/src/`:
|
Key modules in `crates/notfiles/src/`:
|
||||||
|
|
||||||
- **linker** — Creates/removes symlinks or copies. Manages `State` (`.notfiles-state.toml`) tracking every linked file. Handles conflict detection, `--force` backups, empty-parent cleanup on unlink.
|
- **linker** — Creates/removes symlinks or copies via `FileStore` port.
|
||||||
- **config** — Parses `notfiles.toml`. Per-package overrides for method, target, ignore. Falls back to `[defaults]`.
|
Manages `State` (`.notfiles-state.toml`). Returns `LinkResult` with
|
||||||
- **package** — Discovers packages (non-hidden subdirs) and recursively collects files via `IgnoreMatcher`.
|
linked/copied/skipped/backed_up counts. Also provides `adopt_files`.
|
||||||
|
- **package** — Discovers packages with include/exclude and platform
|
||||||
|
filtering. Recursively collects files via `IgnoreMatcher`. Typo
|
||||||
|
suggestions via `suggest_package` on not-found errors.
|
||||||
- **ignore** — Glob-based ignore matching using `globset`.
|
- **ignore** — Glob-based ignore matching using `globset`.
|
||||||
- **status** — Compares expected vs actual state: linked/copied/missing/conflict/orphan.
|
- **status** — Compares expected vs actual state:
|
||||||
|
linked/copied/missing/conflict/orphan. Also provides `diff_package`
|
||||||
|
for copy-method divergence detection.
|
||||||
|
- **adapters/** — `FileStoreImpl`, `InMemoryFileStore`,
|
||||||
|
`TerminalReporter`, `JsonReporter`.
|
||||||
|
- **ports** — `FileStore` trait definition.
|
||||||
|
|
||||||
### notsecrets
|
### notsecrets
|
||||||
|
|
||||||
`AgeKeySource` trait with three implementations: `BitwardenSource` (bw CLI), `FileSource`, `PromptSource`. `resolve_age_key()` tries each in order. `install_age_key()` writes to `~/.config/sops/age/keys.txt` (mode 0600). `decrypt_sops()` shells out to `sops --decrypt`.
|
Multi-provider secret resolution via `SecretResolver`. Providers include
|
||||||
|
`EnvSource`, `OpSource` (1Password), `BitwardenSource`, `FileSource`,
|
||||||
|
`DotenvxSource`, `SopsSource`, and others. Native age encryption/decryption
|
||||||
|
with x25519, SSH ed25519/RSA, and scrypt identity support. No external
|
||||||
|
`sops` or `age` binaries required.
|
||||||
|
|
||||||
### nothooks
|
### nothooks
|
||||||
|
|
||||||
@@ -63,7 +91,14 @@ Orchestrates in order: prereqs check → load config → clone dotfiles → age
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
`notfiles.toml` lives at the dotfiles directory root. Each subdirectory is a "package". Per-package config can override: `method` (symlink/copy), `target` directory, additional `ignore` patterns.
|
`notfiles.toml` lives at the dotfiles directory root. Each subdirectory
|
||||||
|
is a "package".
|
||||||
|
|
||||||
|
Global defaults support `include` (allowlist) or `exclude` (blocklist)
|
||||||
|
for package filtering — mutually exclusive. Per-package config can
|
||||||
|
override: `method` (symlink/copy), `target` directory, additional
|
||||||
|
`ignore` patterns, and `platforms` (e.g. `["linux"]`, `["macos"]`) to
|
||||||
|
gate packages to specific OSes.
|
||||||
|
|
||||||
## Edition
|
## Edition
|
||||||
|
|
||||||
|
|||||||
17
Cargo.lock
generated
17
Cargo.lock
generated
@@ -278,6 +278,15 @@ dependencies = [
|
|||||||
"strsim",
|
"strsim",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_complete"
|
||||||
|
version = "4.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.6.0"
|
version = "4.6.0"
|
||||||
@@ -892,6 +901,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"dirs",
|
"dirs",
|
||||||
"serde",
|
"serde",
|
||||||
|
"strsim",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
@@ -904,10 +914,12 @@ dependencies = [
|
|||||||
"assert_fs",
|
"assert_fs",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"clap_complete",
|
||||||
"dirs",
|
"dirs",
|
||||||
"globset",
|
"globset",
|
||||||
"notcore",
|
"notcore",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
@@ -965,7 +977,6 @@ dependencies = [
|
|||||||
"notcore",
|
"notcore",
|
||||||
"rand",
|
"rand",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
"rsa",
|
|
||||||
"scrypt",
|
"scrypt",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -1256,9 +1267,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.8.5"
|
version = "0.8.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha",
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/89jobrien/notfiles"
|
||||||
|
homepage = "https://github.com/89jobrien/notfiles"
|
||||||
|
keywords = ["dotfiles", "symlink", "stow", "config"]
|
||||||
|
categories = ["command-line-utilities", "filesystem"]
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
@@ -17,10 +22,13 @@ resolver = "2"
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
clap_complete = "4"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
globset = "0.4"
|
globset = "0.4"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
strsim = "0.11"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
which = "7"
|
which = "7"
|
||||||
|
|||||||
64
README.md
64
README.md
@@ -15,10 +15,24 @@ notfiles link
|
|||||||
# Check status
|
# Check status
|
||||||
notfiles status
|
notfiles status
|
||||||
|
|
||||||
|
# Validate config without changes (CI-friendly)
|
||||||
|
notfiles check
|
||||||
|
|
||||||
|
# Show copy-method divergence
|
||||||
|
notfiles diff
|
||||||
|
|
||||||
|
# Move an existing file into a package
|
||||||
|
notfiles adopt git .gitconfig
|
||||||
|
|
||||||
# Remove symlinks
|
# Remove symlinks
|
||||||
notfiles unlink
|
notfiles unlink
|
||||||
|
|
||||||
|
# Generate shell completions
|
||||||
|
notfiles completions bash > ~/.bash_completion.d/notfiles
|
||||||
```
|
```
|
||||||
|
|
||||||
|
All commands support `--dry-run`, `--verbose`, and `--json` flags.
|
||||||
|
|
||||||
On a new machine (once `notstrap` ships):
|
On a new machine (once `notstrap` ships):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -33,11 +47,13 @@ notstrap run
|
|||||||
```
|
```
|
||||||
notfiles/
|
notfiles/
|
||||||
├── crates/
|
├── crates/
|
||||||
│ ├── notcore/ # shared types, config, paths, errors
|
│ ├── notcore/ # shared types, config, paths, errors, Reporter trait
|
||||||
│ ├── notfiles/ # symlink engine (stow replacement) ← you are here
|
│ ├── notfiles/ # symlink engine (stow replacement) ← you are here
|
||||||
│ ├── notsecrets/ # age key retrieval + sops decrypt
|
│ ├── notsecrets/ # multi-provider secret resolution + age crypto
|
||||||
│ ├── nothooks/ # hook execution engine
|
│ ├── nothooks/ # hook execution engine
|
||||||
│ └── notstrap/ # new-machine bootstrap orchestrator
|
│ ├── notnet/ # network utilities (Tailscale, Yubikey)
|
||||||
|
│ ├── notstrap/ # new-machine bootstrap orchestrator
|
||||||
|
│ └── notgraph/ # Rust dependency graph visualizer
|
||||||
├── notfiles.toml # symlink package config
|
├── notfiles.toml # symlink package config
|
||||||
└── notstrap.toml # bootstrap hook/phase config
|
└── notstrap.toml # bootstrap hook/phase config
|
||||||
```
|
```
|
||||||
@@ -46,11 +62,13 @@ notfiles/
|
|||||||
|
|
||||||
| Crate | Type | Does |
|
| Crate | Type | Does |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
| `notcore` | lib | Shared types, config, paths, errors — no deps on other crates |
|
| `notcore` | lib | Shared types, config, paths, errors, Reporter trait — no deps on other crates |
|
||||||
| `notfiles` | lib + bin | Symlink/copy packages into `$HOME`, track state |
|
| `notfiles` | lib + bin | Symlink/copy packages into `$HOME`, track state, adopt/diff/check |
|
||||||
| `notsecrets` | lib | Retrieve age key → sops decrypt → inject secrets |
|
| `notsecrets` | lib | Multi-provider secret resolution, native age encryption/decryption |
|
||||||
| `nothooks` | lib + bin | Run bootstrap hooks in phases, skip already-run setup hooks |
|
| `nothooks` | lib + bin | Run bootstrap hooks in phases, skip already-run setup hooks |
|
||||||
|
| `notnet` | lib | Tailscale integration, Yubikey identity source |
|
||||||
| `notstrap` | bin | Orchestrate everything on a fresh machine |
|
| `notstrap` | bin | Orchestrate everything on a fresh machine |
|
||||||
|
| `notgraph` | lib + bin | Rust file dependency/import graph with HTML/Mermaid output |
|
||||||
|
|
||||||
### Dependency graph
|
### Dependency graph
|
||||||
|
|
||||||
@@ -60,6 +78,7 @@ notstrap
|
|||||||
│ └── notcore
|
│ └── notcore
|
||||||
├── notsecrets
|
├── notsecrets
|
||||||
│ └── notcore
|
│ └── notcore
|
||||||
|
├── notnet
|
||||||
└── nothooks
|
└── nothooks
|
||||||
└── notcore
|
└── notcore
|
||||||
```
|
```
|
||||||
@@ -91,13 +110,16 @@ State is tracked in `.notfiles-state.toml` so `unlink` and `status` know exactly
|
|||||||
```
|
```
|
||||||
notfiles link
|
notfiles link
|
||||||
│
|
│
|
||||||
├─ resolve_packages() discover subdirs or validate requested names
|
├─ config.validate() check include/exclude mutual exclusion
|
||||||
│
|
│
|
||||||
├─ collect_files() recursive walk, apply ignore patterns (globset)
|
├─ resolve_packages_filtered() include/exclude + platform filtering
|
||||||
│
|
│
|
||||||
├─ conflict_check() existing file? symlink to wrong target?
|
├─ collect_files() recursive walk, apply ignore patterns
|
||||||
│
|
│
|
||||||
└─ linker::link_package() create symlinks (or copies), write state
|
├─ conflict_check() existing file? symlink to wrong target?
|
||||||
|
│
|
||||||
|
└─ linker::link_package() create symlinks (or copies), write state,
|
||||||
|
return LinkResult with counters
|
||||||
```
|
```
|
||||||
|
|
||||||
### State file
|
### State file
|
||||||
@@ -156,16 +178,21 @@ Note: 1Password (`op`) is installed as a **hook** in phase `setup` — after sec
|
|||||||
|
|
||||||
## Secrets Bootstrap Detail
|
## Secrets Bootstrap Detail
|
||||||
|
|
||||||
`notsecrets` implements an `AgeKeySource` trait with three sources tried in order:
|
`notsecrets` implements a `SecretResolver` with pluggable provider sources:
|
||||||
|
|
||||||
```
|
```
|
||||||
AgeKeySource
|
SecretResolver
|
||||||
├── BitwardenSource bw unlock → session token → bw get item age-key
|
├── EnvSource read from environment variables
|
||||||
├── FileSource read from --key-file path (USB, etc.)
|
├── OpSource 1Password CLI (op read)
|
||||||
└── PromptSource read from stdin (paste)
|
├── BitwardenSource bw CLI
|
||||||
|
├── FileSource read from file path
|
||||||
|
├── DotenvxSource dotenvx encrypted .env files
|
||||||
|
├── SopsSource SOPS-encrypted files (native age decryption)
|
||||||
|
└── ... (extensible via SecretSource trait)
|
||||||
```
|
```
|
||||||
|
|
||||||
Once the age key is retrieved, it's written to `~/.config/sops/age/keys.txt` and `sops` decrypts `secrets.sops.env`. The decrypted file contains all critical bootstrap credentials (op, bw, github, openai, anthropic, etc.) and is injected into the environment for subsequent hooks.
|
Age encryption/decryption is handled natively (no external `age` or `sops`
|
||||||
|
binaries). Supports x25519, SSH ed25519/RSA, and scrypt identities.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -190,10 +217,15 @@ Once the age key is retrieved, it's written to `~/.config/sops/age/keys.txt` and
|
|||||||
[defaults]
|
[defaults]
|
||||||
method = "symlink"
|
method = "symlink"
|
||||||
target = "~"
|
target = "~"
|
||||||
|
include = ["git", "zsh", "nushell", "starship"] # allowlist (optional)
|
||||||
|
# exclude = ["scratch"] # or blocklist (mutually exclusive)
|
||||||
|
|
||||||
[packages.secrets]
|
[packages.secrets]
|
||||||
method = "copy" # copy instead of symlink for sensitive files
|
method = "copy" # copy instead of symlink for sensitive files
|
||||||
|
|
||||||
|
[packages.nixos]
|
||||||
|
platforms = ["linux"] # only link on Linux
|
||||||
|
|
||||||
[packages.work]
|
[packages.work]
|
||||||
target = "~/work" # different target dir
|
target = "~/work" # different target dir
|
||||||
ignore = ["*.local"]
|
ignore = ["*.local"]
|
||||||
|
|||||||
BIN
crates/.DS_Store
vendored
Normal file
BIN
crates/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -3,10 +3,16 @@ name = "notcore"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
keywords.workspace = true
|
||||||
|
categories.workspace = true
|
||||||
|
description = "Shared types and config for the notfiles dotfiles manager"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
|
strsim = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ pub struct Defaults {
|
|||||||
pub target: String,
|
pub target: String,
|
||||||
#[serde(default = "default_ignore")]
|
#[serde(default = "default_ignore")]
|
||||||
pub ignore: Vec<String>,
|
pub ignore: Vec<String>,
|
||||||
|
/// If set, only these package names are discovered.
|
||||||
|
/// Mutually exclusive with `exclude`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub include: Vec<String>,
|
||||||
|
/// If set, these package names are skipped during discovery.
|
||||||
|
/// Mutually exclusive with `include`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub exclude: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Defaults {
|
impl Default for Defaults {
|
||||||
@@ -25,6 +33,8 @@ impl Default for Defaults {
|
|||||||
Self {
|
Self {
|
||||||
target: default_target(),
|
target: default_target(),
|
||||||
ignore: default_ignore(),
|
ignore: default_ignore(),
|
||||||
|
include: Vec::new(),
|
||||||
|
exclude: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +62,10 @@ pub struct PackageConfig {
|
|||||||
pub target: Option<String>,
|
pub target: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ignore: Vec<String>,
|
pub ignore: Vec<String>,
|
||||||
|
/// If non-empty, this package is only linked on these platforms.
|
||||||
|
/// Valid values: "macos", "linux".
|
||||||
|
#[serde(default)]
|
||||||
|
pub platforms: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
@@ -100,6 +114,50 @@ impl Config {
|
|||||||
.unwrap_or(&self.defaults.target)
|
.unwrap_or(&self.defaults.target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the include list if non-empty, or None (meaning all).
|
||||||
|
pub fn included_packages(&self) -> Option<Vec<&str>> {
|
||||||
|
if self.defaults.include.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let mut v: Vec<&str> = self.defaults.include.iter().map(|s| s.as_str()).collect();
|
||||||
|
v.sort();
|
||||||
|
Some(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if this package name appears in the exclude list.
|
||||||
|
pub fn is_package_excluded(&self, name: &str) -> bool {
|
||||||
|
self.defaults.exclude.iter().any(|e| e == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate config invariants. Returns Err if include and exclude
|
||||||
|
/// are both non-empty.
|
||||||
|
pub fn validate(&self) -> Result<(), NotfilesError> {
|
||||||
|
if !self.defaults.include.is_empty() && !self.defaults.exclude.is_empty() {
|
||||||
|
return Err(NotfilesError::Validation(
|
||||||
|
"include and exclude are mutually exclusive".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the package should be linked on the current OS.
|
||||||
|
/// Empty platforms list means "all platforms".
|
||||||
|
pub fn is_package_for_current_platform(&self, package: &str) -> bool {
|
||||||
|
let platforms = match self.packages.get(package) {
|
||||||
|
Some(pkg) if !pkg.platforms.is_empty() => &pkg.platforms,
|
||||||
|
_ => return true,
|
||||||
|
};
|
||||||
|
let current = if cfg!(target_os = "macos") {
|
||||||
|
"macos"
|
||||||
|
} else if cfg!(target_os = "linux") {
|
||||||
|
"linux"
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
platforms.iter().any(|p| p == current)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ignore_patterns_for(&self, package: &str) -> Vec<&str> {
|
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();
|
let mut patterns: Vec<&str> = self.defaults.ignore.iter().map(|s| s.as_str()).collect();
|
||||||
if let Some(pkg) = self.packages.get(package) {
|
if let Some(pkg) = self.packages.get(package) {
|
||||||
@@ -111,6 +169,23 @@ impl Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Suggest the closest package name if the user made a typo.
|
||||||
|
/// Returns None if no close match (distance > 2).
|
||||||
|
pub fn suggest_package<'a>(name: &str, available: &[&'a str]) -> Option<&'a str> {
|
||||||
|
available
|
||||||
|
.iter()
|
||||||
|
.filter_map(|candidate| {
|
||||||
|
let dist = strsim::damerau_levenshtein(name, candidate);
|
||||||
|
if dist <= 2 {
|
||||||
|
Some((*candidate, dist))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.min_by_key(|(_, d)| *d)
|
||||||
|
.map(|(name, _)| name)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn starter_toml() -> &'static str {
|
pub fn starter_toml() -> &'static str {
|
||||||
r#"[defaults]
|
r#"[defaults]
|
||||||
target = "~"
|
target = "~"
|
||||||
@@ -138,6 +213,92 @@ mod tests {
|
|||||||
assert_eq!(config.target_for("anything"), "~");
|
assert_eq!(config.target_for("anything"), "~");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_include_filter_restricts_packages() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
ignore = [".git"]
|
||||||
|
include = ["git", "zsh", "nushell"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
config.included_packages(),
|
||||||
|
Some(vec!["git", "nushell", "zsh"]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exclude_filter_blocks_packages() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
ignore = [".git"]
|
||||||
|
exclude = ["scripts", "docs", "tests"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert!(config.is_package_excluded("scripts"));
|
||||||
|
assert!(!config.is_package_excluded("zsh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_include_and_exclude_mutual_exclusion() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
include = ["git"]
|
||||||
|
exclude = ["scripts"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_include_no_exclude_returns_none() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert_eq!(config.included_packages(), None);
|
||||||
|
assert!(!config.is_package_excluded("anything"));
|
||||||
|
assert!(config.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_platform_filter_linux_only() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
|
||||||
|
[packages.nixos]
|
||||||
|
platforms = ["linux"]
|
||||||
|
|
||||||
|
[packages.git]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
// On macOS this should be false, on Linux true
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
assert!(!config.is_package_for_current_platform("nixos"));
|
||||||
|
} else if cfg!(target_os = "linux") {
|
||||||
|
assert!(config.is_package_for_current_platform("nixos"));
|
||||||
|
}
|
||||||
|
// git has no platform filter — always matches
|
||||||
|
assert!(config.is_package_for_current_platform("git"));
|
||||||
|
// unknown package — no config entry, always matches
|
||||||
|
assert!(config.is_package_for_current_platform("zsh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_platform_filter_macos_only() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[packages.vscode]
|
||||||
|
platforms = ["macos"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
assert!(config.is_package_for_current_platform("vscode"));
|
||||||
|
} else {
|
||||||
|
assert!(!config.is_package_for_current_platform("vscode"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_config() {
|
fn test_parse_config() {
|
||||||
let toml_str = r#"
|
let toml_str = r#"
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ pub enum NotfilesError {
|
|||||||
#[error("state file error: {0}")]
|
#[error("state file error: {0}")]
|
||||||
State(String),
|
State(String),
|
||||||
|
|
||||||
|
#[error("glob error: {0}")]
|
||||||
|
Glob(String),
|
||||||
|
|
||||||
|
#[error("validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
//! Core types and configuration for the notfiles dotfiles manager.
|
||||||
|
//!
|
||||||
|
//! This crate provides shared types used across the notfiles workspace:
|
||||||
|
//! configuration parsing, error types, path utilities, and the reporter
|
||||||
|
//! trait for output abstraction.
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
|
pub mod reporter;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use config::{Config, Defaults, Method, PackageConfig};
|
pub use config::{Config, Defaults, Method, PackageConfig, suggest_package};
|
||||||
pub use error::NotfilesError;
|
pub use error::NotfilesError;
|
||||||
pub use paths::{dotfiles_dir, expand_tilde};
|
pub use paths::{dotfiles_dir, expand_tilde};
|
||||||
|
pub use reporter::{LinkEvent, Reporter, SilentReporter};
|
||||||
pub use types::{HookPhase, HookSpec, PackageSpec, Report, Step, StepStatus};
|
pub use types::{HookPhase, HookSpec, PackageSpec, Report, Step, StepStatus};
|
||||||
|
|||||||
46
crates/notcore/src/reporter.rs
Normal file
46
crates/notcore/src/reporter.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Events emitted during link/unlink/status operations.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum LinkEvent<'a> {
|
||||||
|
Link {
|
||||||
|
source: &'a str,
|
||||||
|
target: &'a Path,
|
||||||
|
},
|
||||||
|
Copy {
|
||||||
|
source: &'a str,
|
||||||
|
target: &'a Path,
|
||||||
|
},
|
||||||
|
Skip {
|
||||||
|
source: &'a str,
|
||||||
|
reason: &'a str,
|
||||||
|
},
|
||||||
|
Backup {
|
||||||
|
from: &'a Path,
|
||||||
|
to: &'a Path,
|
||||||
|
},
|
||||||
|
Remove {
|
||||||
|
target: &'a Path,
|
||||||
|
},
|
||||||
|
CreateDir {
|
||||||
|
path: &'a Path,
|
||||||
|
},
|
||||||
|
DryRun {
|
||||||
|
action: &'a str,
|
||||||
|
source: &'a str,
|
||||||
|
target: &'a Path,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Port for reporting link/unlink progress. Library code calls this
|
||||||
|
/// instead of println!.
|
||||||
|
pub trait Reporter {
|
||||||
|
fn report(&self, event: &LinkEvent<'_>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No-op reporter for when output is unwanted.
|
||||||
|
pub struct SilentReporter;
|
||||||
|
|
||||||
|
impl Reporter for SilentReporter {
|
||||||
|
fn report(&self, _event: &LinkEvent<'_>) {}
|
||||||
|
}
|
||||||
BIN
crates/notfiles/.DS_Store
vendored
Normal file
BIN
crates/notfiles/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -3,6 +3,10 @@ name = "notfiles"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
keywords.workspace = true
|
||||||
|
categories.workspace = true
|
||||||
description = "A modern dotfiles manager — pure Rust alternative to GNU Stow"
|
description = "A modern dotfiles manager — pure Rust alternative to GNU Stow"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
@@ -16,9 +20,11 @@ path = "src/lib.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
notcore = { workspace = true }
|
notcore = { workspace = true }
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
|
clap_complete = { workspace = true }
|
||||||
globset = { workspace = true }
|
globset = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
|
|||||||
293
crates/notfiles/src/adapters/memory.rs
Normal file
293
crates/notfiles/src/adapters/memory.rs
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::ports::FileStore;
|
||||||
|
|
||||||
|
/// In-memory file system for testing. Not thread-safe.
|
||||||
|
pub struct InMemoryFileStore {
|
||||||
|
files: RefCell<HashMap<PathBuf, Vec<u8>>>,
|
||||||
|
dirs: RefCell<HashSet<PathBuf>>,
|
||||||
|
symlinks: RefCell<HashMap<PathBuf, PathBuf>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryFileStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
files: RefCell::new(HashMap::new()),
|
||||||
|
dirs: RefCell::new(HashSet::new()),
|
||||||
|
symlinks: RefCell::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_file(&self, path: impl AsRef<Path>, content: &[u8]) {
|
||||||
|
let path = path.as_ref().to_path_buf();
|
||||||
|
// Ensure parent dirs exist
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
self.ensure_parents(parent);
|
||||||
|
}
|
||||||
|
self.files.borrow_mut().insert(path, content.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_dir(&self, path: impl AsRef<Path>) {
|
||||||
|
self.ensure_parents(path.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_symlink(&self, link: &Path) -> bool {
|
||||||
|
self.symlinks.borrow().contains_key(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn symlink_target(&self, link: &Path) -> Option<PathBuf> {
|
||||||
|
self.symlinks.borrow().get(link).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_parents(&self, path: &Path) {
|
||||||
|
let mut current = path.to_path_buf();
|
||||||
|
let mut dirs = self.dirs.borrow_mut();
|
||||||
|
loop {
|
||||||
|
if !dirs.insert(current.clone()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match current.parent() {
|
||||||
|
Some(p) if p != current => current = p.to_path_buf(),
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InMemoryFileStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileStore for InMemoryFileStore {
|
||||||
|
fn read(&self, path: &Path) -> Result<Vec<u8>, std::io::Error> {
|
||||||
|
// Follow symlinks
|
||||||
|
if let Some(target) = self.symlinks.borrow().get(path) {
|
||||||
|
return self.read(target);
|
||||||
|
}
|
||||||
|
self.files.borrow().get(path).cloned().ok_or_else(|| {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::NotFound, path.display().to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_to_string(&self, path: &Path) -> Result<String, std::io::Error> {
|
||||||
|
let bytes = self.read(path)?;
|
||||||
|
String::from_utf8(bytes)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, path: &Path, contents: &[u8]) -> Result<(), std::io::Error> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
self.ensure_parents(parent);
|
||||||
|
}
|
||||||
|
self.files
|
||||||
|
.borrow_mut()
|
||||||
|
.insert(path.to_path_buf(), contents.to_vec());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename(&self, from: &Path, to: &Path) -> Result<(), std::io::Error> {
|
||||||
|
let content = self.files.borrow_mut().remove(from);
|
||||||
|
if let Some(content) = content {
|
||||||
|
if let Some(parent) = to.parent() {
|
||||||
|
self.ensure_parents(parent);
|
||||||
|
}
|
||||||
|
self.files.borrow_mut().insert(to.to_path_buf(), content);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if self.symlinks.borrow_mut().remove(from).is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
from.display().to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_file(&self, path: &Path) -> Result<(), std::io::Error> {
|
||||||
|
if self.files.borrow_mut().remove(path).is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if self.symlinks.borrow_mut().remove(path).is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
path.display().to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_dir_all(&self, path: &Path) -> Result<(), std::io::Error> {
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
self.files.borrow_mut().retain(|k, _| !k.starts_with(&path));
|
||||||
|
self.dirs.borrow_mut().retain(|k| !k.starts_with(&path));
|
||||||
|
self.symlinks
|
||||||
|
.borrow_mut()
|
||||||
|
.retain(|k, _| !k.starts_with(&path));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_dir(&self, path: &Path) -> Result<(), std::io::Error> {
|
||||||
|
self.dirs.borrow_mut().remove(path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_link(&self, path: &Path) -> Result<PathBuf, std::io::Error> {
|
||||||
|
self.symlinks.borrow().get(path).cloned().ok_or_else(|| {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::NotFound, path.display().to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn symlink_metadata(&self, path: &Path) -> Result<std::fs::Metadata, std::io::Error> {
|
||||||
|
// We can't construct real Metadata, but we can indicate existence
|
||||||
|
// by returning NotFound when the path doesn't exist
|
||||||
|
if self.files.borrow().contains_key(path)
|
||||||
|
|| self.dirs.borrow().contains(path)
|
||||||
|
|| self.symlinks.borrow().contains_key(path)
|
||||||
|
{
|
||||||
|
// Return metadata of a real temp file as a stand-in
|
||||||
|
// This is a known limitation of the in-memory store
|
||||||
|
Err(std::io::Error::other(
|
||||||
|
"InMemoryFileStore: metadata not available",
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
path.display().to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata(&self, path: &Path) -> Result<std::fs::Metadata, std::io::Error> {
|
||||||
|
self.symlink_metadata(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_dir_all(&self, path: &Path) -> Result<(), std::io::Error> {
|
||||||
|
self.ensure_parents(path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, std::io::Error> {
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
let mut children = Vec::new();
|
||||||
|
|
||||||
|
for key in self.files.borrow().keys() {
|
||||||
|
if key.parent() == Some(&path) {
|
||||||
|
children.push(key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for dir in self.dirs.borrow().iter() {
|
||||||
|
if dir.parent() == Some(&path) && dir != &path && !children.contains(dir) {
|
||||||
|
children.push(dir.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in self.symlinks.borrow().keys() {
|
||||||
|
if key.parent() == Some(&path) && !children.contains(key) {
|
||||||
|
children.push(key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(children)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn symlink(&self, target: &Path, link: &Path) -> Result<(), std::io::Error> {
|
||||||
|
if let Some(parent) = link.parent() {
|
||||||
|
self.ensure_parents(parent);
|
||||||
|
}
|
||||||
|
self.symlinks
|
||||||
|
.borrow_mut()
|
||||||
|
.insert(link.to_path_buf(), target.to_path_buf());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exists(&self, path: &Path) -> bool {
|
||||||
|
self.files.borrow().contains_key(path)
|
||||||
|
|| self.dirs.borrow().contains(path)
|
||||||
|
|| self.symlinks.borrow().contains_key(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_dir(&self, path: &Path) -> bool {
|
||||||
|
self.dirs.borrow().contains(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_file_creates_parents() {
|
||||||
|
let fs = InMemoryFileStore::new();
|
||||||
|
fs.add_file("/a/b/c.txt", b"hello");
|
||||||
|
assert!(fs.is_dir(Path::new("/a")));
|
||||||
|
assert!(fs.is_dir(Path::new("/a/b")));
|
||||||
|
assert!(fs.exists(Path::new("/a/b/c.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_write_roundtrip() {
|
||||||
|
let fs = InMemoryFileStore::new();
|
||||||
|
fs.write(Path::new("/tmp/f.txt"), b"data").unwrap();
|
||||||
|
assert_eq!(fs.read(Path::new("/tmp/f.txt")).unwrap(), b"data");
|
||||||
|
assert_eq!(fs.read_to_string(Path::new("/tmp/f.txt")).unwrap(), "data");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_symlink_roundtrip() {
|
||||||
|
let fs = InMemoryFileStore::new();
|
||||||
|
fs.add_file("/src/config", b"content");
|
||||||
|
fs.symlink(Path::new("/src/config"), Path::new("/home/config"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(fs.has_symlink(Path::new("/home/config")));
|
||||||
|
assert_eq!(
|
||||||
|
fs.read_link(Path::new("/home/config")).unwrap(),
|
||||||
|
PathBuf::from("/src/config")
|
||||||
|
);
|
||||||
|
// Reading through symlink
|
||||||
|
assert_eq!(fs.read(Path::new("/home/config")).unwrap(), b"content");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rename_file() {
|
||||||
|
let fs = InMemoryFileStore::new();
|
||||||
|
fs.add_file("/a.txt", b"hello");
|
||||||
|
fs.rename(Path::new("/a.txt"), Path::new("/b.txt")).unwrap();
|
||||||
|
assert!(!fs.exists(Path::new("/a.txt")));
|
||||||
|
assert_eq!(fs.read(Path::new("/b.txt")).unwrap(), b"hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove_dir_all() {
|
||||||
|
let fs = InMemoryFileStore::new();
|
||||||
|
fs.add_file("/dir/a.txt", b"a");
|
||||||
|
fs.add_file("/dir/sub/b.txt", b"b");
|
||||||
|
fs.remove_dir_all(Path::new("/dir")).unwrap();
|
||||||
|
assert!(!fs.exists(Path::new("/dir/a.txt")));
|
||||||
|
assert!(!fs.exists(Path::new("/dir/sub/b.txt")));
|
||||||
|
assert!(!fs.is_dir(Path::new("/dir")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_dir_lists_children() {
|
||||||
|
let fs = InMemoryFileStore::new();
|
||||||
|
fs.add_file("/dir/a.txt", b"a");
|
||||||
|
fs.add_file("/dir/b.txt", b"b");
|
||||||
|
fs.add_dir("/dir/sub");
|
||||||
|
let mut children = fs.read_dir(Path::new("/dir")).unwrap();
|
||||||
|
children.sort();
|
||||||
|
assert_eq!(
|
||||||
|
children,
|
||||||
|
vec![
|
||||||
|
PathBuf::from("/dir/a.txt"),
|
||||||
|
PathBuf::from("/dir/b.txt"),
|
||||||
|
PathBuf::from("/dir/sub"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
pub mod fs;
|
pub mod fs;
|
||||||
|
pub mod memory;
|
||||||
|
pub mod reporter;
|
||||||
|
|
||||||
pub use fs::FileStoreImpl;
|
pub use fs::FileStoreImpl;
|
||||||
|
pub use memory::InMemoryFileStore;
|
||||||
|
pub use reporter::{JsonReporter, TerminalReporter};
|
||||||
|
|||||||
81
crates/notfiles/src/adapters/reporter.rs
Normal file
81
crates/notfiles/src/adapters/reporter.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use notcore::reporter::{LinkEvent, Reporter};
|
||||||
|
|
||||||
|
/// Terminal reporter with ANSI color output.
|
||||||
|
pub struct TerminalReporter;
|
||||||
|
|
||||||
|
/// JSON reporter: one JSON object per line to stdout.
|
||||||
|
pub struct JsonReporter;
|
||||||
|
|
||||||
|
impl Reporter for TerminalReporter {
|
||||||
|
fn report(&self, event: &LinkEvent<'_>) {
|
||||||
|
match event {
|
||||||
|
LinkEvent::Link { source, target } => {
|
||||||
|
println!(" \x1b[32mlink\x1b[0m {source} -> {}", target.display());
|
||||||
|
}
|
||||||
|
LinkEvent::Copy { source, target } => {
|
||||||
|
println!(" \x1b[32mcopy\x1b[0m {source} -> {}", target.display());
|
||||||
|
}
|
||||||
|
LinkEvent::Skip { source, reason } => {
|
||||||
|
println!(" \x1b[90mskip\x1b[0m {source} ({reason})");
|
||||||
|
}
|
||||||
|
LinkEvent::Backup { from, to } => {
|
||||||
|
println!(
|
||||||
|
" \x1b[33mbackup\x1b[0m {} -> {}",
|
||||||
|
from.display(),
|
||||||
|
to.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
LinkEvent::Remove { target } => {
|
||||||
|
println!(" \x1b[31mremove\x1b[0m {}", target.display());
|
||||||
|
}
|
||||||
|
LinkEvent::CreateDir { path } => {
|
||||||
|
println!(" \x1b[90mcreate dir\x1b[0m {}", path.display());
|
||||||
|
}
|
||||||
|
LinkEvent::DryRun {
|
||||||
|
action,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
} => {
|
||||||
|
println!(
|
||||||
|
" \x1b[36mwould {action}\x1b[0m {source} -> {}",
|
||||||
|
target.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reporter for JsonReporter {
|
||||||
|
fn report(&self, event: &LinkEvent<'_>) {
|
||||||
|
let obj = match event {
|
||||||
|
LinkEvent::Link { source, target } => {
|
||||||
|
json!({"event": "link", "source": source, "target": target.to_string_lossy()})
|
||||||
|
}
|
||||||
|
LinkEvent::Copy { source, target } => {
|
||||||
|
json!({"event": "copy", "source": source, "target": target.to_string_lossy()})
|
||||||
|
}
|
||||||
|
LinkEvent::Skip { source, reason } => {
|
||||||
|
json!({"event": "skip", "source": source, "reason": reason})
|
||||||
|
}
|
||||||
|
LinkEvent::Backup { from, to } => {
|
||||||
|
json!({"event": "backup", "from": from.to_string_lossy(), "to": to.to_string_lossy()})
|
||||||
|
}
|
||||||
|
LinkEvent::Remove { target } => {
|
||||||
|
json!({"event": "remove", "target": target.to_string_lossy()})
|
||||||
|
}
|
||||||
|
LinkEvent::CreateDir { path } => {
|
||||||
|
json!({"event": "create_dir", "path": path.to_string_lossy()})
|
||||||
|
}
|
||||||
|
LinkEvent::DryRun {
|
||||||
|
action,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
} => {
|
||||||
|
json!({"event": "dry_run", "action": action, "source": source, "target": target.to_string_lossy()})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ impl IgnoreMatcher {
|
|||||||
let glob = Glob::new(pattern)
|
let glob = Glob::new(pattern)
|
||||||
.or_else(|_| Glob::new(&format!("**/{pattern}")))
|
.or_else(|_| Glob::new(&format!("**/{pattern}")))
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
NotfilesError::Other(format!("invalid ignore pattern '{pattern}': {e}"))
|
NotfilesError::Glob(format!("invalid ignore pattern '{pattern}': {e}"))
|
||||||
})?;
|
})?;
|
||||||
builder.add(glob);
|
builder.add(glob);
|
||||||
// Also add a recursive variant so "foo" matches "a/foo" etc.
|
// Also add a recursive variant so "foo" matches "a/foo" etc.
|
||||||
@@ -28,7 +28,7 @@ impl IgnoreMatcher {
|
|||||||
}
|
}
|
||||||
let globset = builder
|
let globset = builder
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| NotfilesError::Other(format!("building ignore set: {e}")))?;
|
.map_err(|e| NotfilesError::Glob(format!("building ignore set: {e}")))?;
|
||||||
Ok(Self { globset })
|
Ok(Self { globset })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::package::collect_files_with_store;
|
use crate::package::collect_files_with_store;
|
||||||
use crate::ports::FileStore;
|
use crate::ports::FileStore;
|
||||||
|
use notcore::reporter::{LinkEvent, Reporter};
|
||||||
use notcore::{Config, Method, NotfilesError, expand_tilde};
|
use notcore::{Config, Method, NotfilesError, expand_tilde};
|
||||||
|
|
||||||
const STATE_FILE: &str = ".notfiles-state.toml";
|
const STATE_FILE: &str = ".notfiles-state.toml";
|
||||||
@@ -81,6 +82,15 @@ pub struct LinkOptions {
|
|||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct LinkResult {
|
||||||
|
pub package: String,
|
||||||
|
pub linked: usize,
|
||||||
|
pub copied: usize,
|
||||||
|
pub skipped: usize,
|
||||||
|
pub backed_up: usize,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn link_package(
|
pub fn link_package(
|
||||||
dotfiles_dir: &Path,
|
dotfiles_dir: &Path,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
@@ -88,17 +98,23 @@ pub fn link_package(
|
|||||||
package: &str,
|
package: &str,
|
||||||
opts: &LinkOptions,
|
opts: &LinkOptions,
|
||||||
fs: &dyn FileStore,
|
fs: &dyn FileStore,
|
||||||
) -> Result<(), NotfilesError> {
|
reporter: &dyn Reporter,
|
||||||
|
) -> Result<LinkResult, NotfilesError> {
|
||||||
let package_dir = dotfiles_dir.join(package);
|
let package_dir = dotfiles_dir.join(package);
|
||||||
let method = config.method_for(package);
|
let method = config.method_for(package);
|
||||||
let target_base = expand_tilde(config.target_for(package))?;
|
let target_base = expand_tilde(config.target_for(package))?;
|
||||||
let files = collect_files_with_store(&package_dir, config, package, fs)?;
|
let files = collect_files_with_store(&package_dir, config, package, fs)?;
|
||||||
|
let mut result = LinkResult {
|
||||||
|
package: package.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
if files.is_empty() {
|
if files.is_empty() {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" {package}: no files to link");
|
source: package,
|
||||||
}
|
reason: "no files to link",
|
||||||
return Ok(());
|
});
|
||||||
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
for relative in &files {
|
for relative in &files {
|
||||||
@@ -108,19 +124,22 @@ pub fn link_package(
|
|||||||
|
|
||||||
// Check if already correctly linked / copied
|
// Check if already correctly linked / copied
|
||||||
if is_already_linked(&source, &target, method, fs) {
|
if is_already_linked(&source, &target, method, fs) {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" \x1b[90mskip\x1b[0m {source_display} (already linked)");
|
source: &source_display,
|
||||||
}
|
reason: "already linked",
|
||||||
|
});
|
||||||
|
result.skipped += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: save partial state then return an error.
|
// Helper: save partial state then return an error.
|
||||||
let save_and_return = |state: &mut State, e: NotfilesError| -> Result<(), NotfilesError> {
|
let save_and_return =
|
||||||
if !opts.dry_run {
|
|state: &mut State, e: NotfilesError| -> Result<LinkResult, NotfilesError> {
|
||||||
let _ = state.save(dotfiles_dir, fs);
|
if !opts.dry_run {
|
||||||
}
|
let _ = state.save(dotfiles_dir, fs);
|
||||||
Err(e)
|
}
|
||||||
};
|
Err(e)
|
||||||
|
};
|
||||||
|
|
||||||
// Conflict detection
|
// Conflict detection
|
||||||
if fs.exists(&target) || fs.symlink_metadata(&target).is_ok() {
|
if fs.exists(&target) || fs.symlink_metadata(&target).is_ok() {
|
||||||
@@ -139,22 +158,20 @@ pub fn link_package(
|
|||||||
if !opts.no_backup {
|
if !opts.no_backup {
|
||||||
let backup = backup_path(&target);
|
let backup = backup_path(&target);
|
||||||
if opts.dry_run {
|
if opts.dry_run {
|
||||||
println!(
|
reporter.report(&LinkEvent::DryRun {
|
||||||
" \x1b[33mwould backup\x1b[0m {} -> {}",
|
action: "backup",
|
||||||
target.display(),
|
source: &source_display,
|
||||||
backup.display()
|
target: &backup,
|
||||||
);
|
});
|
||||||
} else {
|
} else {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Backup {
|
||||||
println!(
|
from: &target,
|
||||||
" \x1b[33mbackup\x1b[0m {} -> {}",
|
to: &backup,
|
||||||
target.display(),
|
});
|
||||||
backup.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Err(e) = fs.rename(&target, &backup) {
|
if let Err(e) = fs.rename(&target, &backup) {
|
||||||
return save_and_return(state, e.into());
|
return save_and_return(state, e.into());
|
||||||
}
|
}
|
||||||
|
result.backed_up += 1;
|
||||||
}
|
}
|
||||||
} else if !opts.dry_run {
|
} else if !opts.dry_run {
|
||||||
let rm_result = if fs.is_dir(&target) {
|
let rm_result = if fs.is_dir(&target) {
|
||||||
@@ -171,9 +188,7 @@ pub fn link_package(
|
|||||||
// Create parent directories
|
// Create parent directories
|
||||||
if let Some(parent) = target.parent().filter(|p| !fs.exists(p)) {
|
if let Some(parent) = target.parent().filter(|p| !fs.exists(p)) {
|
||||||
if opts.dry_run {
|
if opts.dry_run {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::CreateDir { path: parent });
|
||||||
println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display());
|
|
||||||
}
|
|
||||||
} else if let Err(e) = fs.create_dir_all(parent) {
|
} else if let Err(e) = fs.create_dir_all(parent) {
|
||||||
return save_and_return(state, e.into());
|
return save_and_return(state, e.into());
|
||||||
}
|
}
|
||||||
@@ -186,12 +201,13 @@ pub fn link_package(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if opts.dry_run {
|
if opts.dry_run {
|
||||||
println!(
|
reporter.report(&LinkEvent::DryRun {
|
||||||
" \x1b[36mwould {action_word}\x1b[0m {source_display} -> {}",
|
action: action_word,
|
||||||
target.display()
|
source: &source_display,
|
||||||
);
|
target: &target,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
let link_result = match method {
|
let io_result = match method {
|
||||||
Method::Symlink => {
|
Method::Symlink => {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -210,14 +226,24 @@ pub fn link_package(
|
|||||||
fs.write(&target, &content).map(|_| content.len() as u64)
|
fs.write(&target, &content).map(|_| content.len() as u64)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = link_result {
|
if let Err(e) = io_result {
|
||||||
return save_and_return(state, e.into());
|
return save_and_return(state, e.into());
|
||||||
}
|
}
|
||||||
if opts.verbose {
|
match method {
|
||||||
println!(
|
Method::Symlink => {
|
||||||
" \x1b[32m{action_word}\x1b[0m {source_display} -> {}",
|
reporter.report(&LinkEvent::Link {
|
||||||
target.display()
|
source: &source_display,
|
||||||
);
|
target: &target,
|
||||||
|
});
|
||||||
|
result.linked += 1;
|
||||||
|
}
|
||||||
|
Method::Copy => {
|
||||||
|
reporter.report(&LinkEvent::Copy {
|
||||||
|
source: &source_display,
|
||||||
|
target: &target,
|
||||||
|
});
|
||||||
|
result.copied += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.add_entry(StateEntry {
|
state.add_entry(StateEntry {
|
||||||
@@ -233,7 +259,7 @@ pub fn link_package(
|
|||||||
if !opts.dry_run {
|
if !opts.dry_run {
|
||||||
state.save(dotfiles_dir, fs)?;
|
state.save(dotfiles_dir, fs)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unlink_package(
|
pub fn unlink_package(
|
||||||
@@ -242,6 +268,7 @@ pub fn unlink_package(
|
|||||||
package: &str,
|
package: &str,
|
||||||
opts: &LinkOptions,
|
opts: &LinkOptions,
|
||||||
fs: &dyn FileStore,
|
fs: &dyn FileStore,
|
||||||
|
reporter: &dyn Reporter,
|
||||||
) -> Result<(), NotfilesError> {
|
) -> Result<(), NotfilesError> {
|
||||||
// Validate the package: it must either exist as a directory in dotfiles_dir
|
// Validate the package: it must either exist as a directory in dotfiles_dir
|
||||||
// or have entries in state. A name that satisfies neither is a user error.
|
// or have entries in state. A name that satisfies neither is a user error.
|
||||||
@@ -260,9 +287,10 @@ pub fn unlink_package(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" {package}: nothing to unlink");
|
source: package,
|
||||||
}
|
reason: "nothing to unlink",
|
||||||
|
});
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,9 +301,10 @@ pub fn unlink_package(
|
|||||||
let source = PathBuf::from(&entry.source);
|
let source = PathBuf::from(&entry.source);
|
||||||
|
|
||||||
if !fs.exists(&target) && fs.symlink_metadata(&target).is_err() {
|
if !fs.exists(&target) && fs.symlink_metadata(&target).is_err() {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" \x1b[90mskip\x1b[0m {} (already gone)", target.display());
|
source: &target.to_string_lossy(),
|
||||||
}
|
reason: "already gone",
|
||||||
|
});
|
||||||
if !opts.dry_run {
|
if !opts.dry_run {
|
||||||
removed_entries.push((entry.source.clone(), entry.target.clone()));
|
removed_entries.push((entry.source.clone(), entry.target.clone()));
|
||||||
}
|
}
|
||||||
@@ -287,64 +316,59 @@ pub fn unlink_package(
|
|||||||
// Verify it's a symlink pointing to our source
|
// Verify it's a symlink pointing to our source
|
||||||
if let Ok(link_target) = fs.read_link(&target) {
|
if let Ok(link_target) = fs.read_link(&target) {
|
||||||
if link_target != source {
|
if link_target != source {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(
|
source: &target.to_string_lossy(),
|
||||||
" \x1b[33mskip\x1b[0m {} (symlink points elsewhere)",
|
reason: "symlink points elsewhere",
|
||||||
target.display()
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" \x1b[33mskip\x1b[0m {} (not a symlink)", target.display());
|
source: &target.to_string_lossy(),
|
||||||
}
|
reason: "not a symlink",
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::Copy => match (fs.read(&source), fs.read(&target)) {
|
Method::Copy => match (fs.read(&source), fs.read(&target)) {
|
||||||
(Ok(source_bytes), Ok(target_bytes)) if source_bytes == target_bytes => {}
|
(Ok(source_bytes), Ok(target_bytes)) if source_bytes == target_bytes => {}
|
||||||
(Ok(_), Ok(_)) => {
|
(Ok(_), Ok(_)) => {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(
|
source: &target.to_string_lossy(),
|
||||||
" \x1b[33mskip\x1b[0m {} (copied file diverged from source)",
|
reason: "copied file diverged from source",
|
||||||
target.display()
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
(Err(_), _) => {
|
(Err(_), _) => {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(
|
source: &target.to_string_lossy(),
|
||||||
" \x1b[33mskip\x1b[0m {} (source missing for copied file)",
|
reason: "source missing for copied file",
|
||||||
target.display()
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
(_, Err(_)) => {
|
(_, Err(_)) => {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(
|
source: &target.to_string_lossy(),
|
||||||
" \x1b[33mskip\x1b[0m {} (cannot read copied target)",
|
reason: "cannot read copied target",
|
||||||
target.display()
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.dry_run {
|
if opts.dry_run {
|
||||||
println!(" \x1b[36mwould remove\x1b[0m {}", target.display());
|
reporter.report(&LinkEvent::DryRun {
|
||||||
|
action: "remove",
|
||||||
|
source: &target.to_string_lossy(),
|
||||||
|
target: &target,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if fs.is_dir(&target) {
|
if fs.is_dir(&target) {
|
||||||
fs.remove_dir_all(&target)?;
|
fs.remove_dir_all(&target)?;
|
||||||
} else {
|
} else {
|
||||||
fs.remove_file(&target)?;
|
fs.remove_file(&target)?;
|
||||||
}
|
}
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Remove { target: &target });
|
||||||
println!(" \x1b[31mremove\x1b[0m {}", target.display());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up empty parent dirs
|
// Clean up empty parent dirs
|
||||||
cleanup_empty_parents(&target, fs);
|
cleanup_empty_parents(&target, fs);
|
||||||
@@ -362,6 +386,100 @@ pub fn unlink_package(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn adopt_files(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
config: &Config,
|
||||||
|
state: &mut State,
|
||||||
|
package: &str,
|
||||||
|
files: &[String],
|
||||||
|
opts: &LinkOptions,
|
||||||
|
fs: &dyn FileStore,
|
||||||
|
reporter: &dyn Reporter,
|
||||||
|
) -> Result<LinkResult, NotfilesError> {
|
||||||
|
let package_dir = dotfiles_dir.join(package);
|
||||||
|
let target_base = expand_tilde(config.target_for(package))?;
|
||||||
|
let mut result = LinkResult {
|
||||||
|
package: package.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !fs.is_dir(&package_dir) {
|
||||||
|
if opts.dry_run {
|
||||||
|
reporter.report(&LinkEvent::CreateDir { path: &package_dir });
|
||||||
|
} else {
|
||||||
|
fs.create_dir_all(&package_dir)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for file in files {
|
||||||
|
let relative = PathBuf::from(file);
|
||||||
|
let target = target_base.join(&relative);
|
||||||
|
let source = package_dir.join(&relative);
|
||||||
|
let display = format!("{package}/{}", relative.display());
|
||||||
|
|
||||||
|
if !fs.exists(&target) {
|
||||||
|
reporter.report(&LinkEvent::Skip {
|
||||||
|
source: &display,
|
||||||
|
reason: "target file does not exist",
|
||||||
|
});
|
||||||
|
result.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if fs.exists(&source) {
|
||||||
|
reporter.report(&LinkEvent::Skip {
|
||||||
|
source: &display,
|
||||||
|
reason: "already exists in package",
|
||||||
|
});
|
||||||
|
result.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.dry_run {
|
||||||
|
reporter.report(&LinkEvent::DryRun {
|
||||||
|
action: "adopt",
|
||||||
|
source: &display,
|
||||||
|
target: &target,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create parent dirs in package
|
||||||
|
if let Some(parent) = source.parent().filter(|p| !fs.exists(p)) {
|
||||||
|
fs.create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move target -> package source
|
||||||
|
fs.rename(&target, &source)?;
|
||||||
|
|
||||||
|
// Create symlink target -> source
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
fs.symlink(&source, &target)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
reporter.report(&LinkEvent::Link {
|
||||||
|
source: &display,
|
||||||
|
target: &target,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.add_entry(StateEntry {
|
||||||
|
package: package.to_string(),
|
||||||
|
source: source.to_string_lossy().to_string(),
|
||||||
|
target: target.to_string_lossy().to_string(),
|
||||||
|
method: Method::Symlink,
|
||||||
|
linked_at: Utc::now().to_rfc3339(),
|
||||||
|
});
|
||||||
|
result.linked += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.dry_run {
|
||||||
|
state.save(dotfiles_dir, fs)?;
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
fn is_already_linked(source: &Path, target: &Path, method: Method, fs: &dyn FileStore) -> bool {
|
fn is_already_linked(source: &Path, target: &Path, method: Method, fs: &dyn FileStore) -> bool {
|
||||||
match method {
|
match method {
|
||||||
Method::Symlink => {
|
Method::Symlink => {
|
||||||
|
|||||||
@@ -5,6 +5,38 @@ use crate::ignore::IgnoreMatcher;
|
|||||||
use crate::ports::FileStore;
|
use crate::ports::FileStore;
|
||||||
use notcore::{Config, NotfilesError};
|
use notcore::{Config, NotfilesError};
|
||||||
|
|
||||||
|
/// Discover packages filtered by the config's include/exclude lists and
|
||||||
|
/// platform constraints.
|
||||||
|
pub fn discover_packages_filtered(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<Vec<String>, NotfilesError> {
|
||||||
|
discover_packages_filtered_with_store(dotfiles_dir, config, &FileStoreImpl)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover_packages_filtered_with_store(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
config: &Config,
|
||||||
|
fs: &dyn FileStore,
|
||||||
|
) -> Result<Vec<String>, NotfilesError> {
|
||||||
|
let all = discover_packages_with_store(dotfiles_dir, fs)?;
|
||||||
|
let filtered: Vec<String> = if let Some(include) = config.included_packages() {
|
||||||
|
all.into_iter()
|
||||||
|
.filter(|p| include.contains(&p.as_str()))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
all.into_iter()
|
||||||
|
.filter(|p| !config.is_package_excluded(p))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
// Apply platform filter
|
||||||
|
let filtered = filtered
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| config.is_package_for_current_platform(p))
|
||||||
|
.collect();
|
||||||
|
Ok(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
/// Discover available packages (subdirectories of the dotfiles dir).
|
/// Discover available packages (subdirectories of the dotfiles dir).
|
||||||
pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> {
|
pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> {
|
||||||
discover_packages_with_store(dotfiles_dir, &FileStoreImpl)
|
discover_packages_with_store(dotfiles_dir, &FileStoreImpl)
|
||||||
@@ -48,13 +80,28 @@ pub fn resolve_packages_with_store(
|
|||||||
requested: &[String],
|
requested: &[String],
|
||||||
fs: &dyn FileStore,
|
fs: &dyn FileStore,
|
||||||
) -> Result<Vec<String>, NotfilesError> {
|
) -> Result<Vec<String>, NotfilesError> {
|
||||||
let available = discover_packages_with_store(dotfiles_dir, fs)?;
|
resolve_packages_filtered_with_store(dotfiles_dir, requested, &Config::default(), fs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_packages_filtered_with_store(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
requested: &[String],
|
||||||
|
config: &Config,
|
||||||
|
fs: &dyn FileStore,
|
||||||
|
) -> Result<Vec<String>, NotfilesError> {
|
||||||
|
let available = discover_packages_filtered_with_store(dotfiles_dir, config, fs)?;
|
||||||
if requested.is_empty() {
|
if requested.is_empty() {
|
||||||
return Ok(available);
|
return Ok(available);
|
||||||
}
|
}
|
||||||
for name in requested {
|
for name in requested {
|
||||||
if !available.contains(name) {
|
if !available.contains(name) {
|
||||||
return Err(NotfilesError::PackageNotFound { name: name.clone() });
|
let available_refs: Vec<&str> = available.iter().map(|s| s.as_str()).collect();
|
||||||
|
let suggestion = notcore::suggest_package(name, &available_refs);
|
||||||
|
let mut msg = name.clone();
|
||||||
|
if let Some(s) = suggestion {
|
||||||
|
msg = format!("{name} (did you mean '{s}'?)");
|
||||||
|
}
|
||||||
|
return Err(NotfilesError::PackageNotFound { name: msg });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(requested.to_vec())
|
Ok(requested.to_vec())
|
||||||
@@ -123,6 +170,40 @@ mod tests {
|
|||||||
assert_eq!(pkgs, vec!["git", "zsh"]);
|
assert_eq!(pkgs, vec!["git", "zsh"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_discover_respects_include_filter() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("git")).unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("zsh")).unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("scripts")).unwrap();
|
||||||
|
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
include = ["git", "zsh"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
let pkgs = discover_packages_filtered(tmp.path(), &config).unwrap();
|
||||||
|
assert_eq!(pkgs, vec!["git", "zsh"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_discover_respects_exclude_filter() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("git")).unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("scripts")).unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("docs")).unwrap();
|
||||||
|
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
exclude = ["scripts", "docs"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
let pkgs = discover_packages_filtered(tmp.path(), &config).unwrap();
|
||||||
|
assert_eq!(pkgs, vec!["git"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_specific_packages() {
|
fn test_resolve_specific_packages() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::linker::State;
|
use crate::linker::State;
|
||||||
use crate::package::collect_files_with_store;
|
use crate::package::collect_files_with_store;
|
||||||
use crate::ports::FileStore;
|
use crate::ports::FileStore;
|
||||||
@@ -121,6 +123,126 @@ pub fn package_status(
|
|||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub enum DiffKind {
|
||||||
|
Identical,
|
||||||
|
Modified,
|
||||||
|
SourceOnly,
|
||||||
|
TargetOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DiffEntry {
|
||||||
|
pub source_display: String,
|
||||||
|
pub target: PathBuf,
|
||||||
|
pub kind: DiffKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn diff_package(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
config: &Config,
|
||||||
|
state: &State,
|
||||||
|
package: &str,
|
||||||
|
fs: &dyn FileStore,
|
||||||
|
) -> Vec<DiffEntry> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let method = config.method_for(package);
|
||||||
|
if method != Method::Copy {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
let package_dir = dotfiles_dir.join(package);
|
||||||
|
let target_base = match expand_tilde(config.target_for(package)) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return results,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check files tracked in state for this package
|
||||||
|
for entry in state.entries_for_package(package) {
|
||||||
|
let source = PathBuf::from(&entry.source);
|
||||||
|
let target = PathBuf::from(&entry.target);
|
||||||
|
let source_display = format!(
|
||||||
|
"{package}/{}",
|
||||||
|
source
|
||||||
|
.strip_prefix(&package_dir)
|
||||||
|
.unwrap_or(&source)
|
||||||
|
.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let kind = match (fs.read(&source), fs.read(&target)) {
|
||||||
|
(Ok(src), Ok(tgt)) => {
|
||||||
|
if src == tgt {
|
||||||
|
DiffKind::Identical
|
||||||
|
} else {
|
||||||
|
DiffKind::Modified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Ok(_), Err(_)) => DiffKind::SourceOnly,
|
||||||
|
(Err(_), Ok(_)) => DiffKind::TargetOnly,
|
||||||
|
(Err(_), Err(_)) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push(DiffEntry {
|
||||||
|
source_display,
|
||||||
|
target,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check untracked files from the package dir
|
||||||
|
if let Ok(files) = collect_files_with_store(&package_dir, config, package, fs) {
|
||||||
|
for relative in &files {
|
||||||
|
let source = package_dir.join(relative);
|
||||||
|
let target = target_base.join(relative);
|
||||||
|
// Skip if already covered by state entries
|
||||||
|
if results.iter().any(|r| r.target == target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let source_display = format!("{package}/{}", relative.display());
|
||||||
|
let kind = if !fs.exists(&target) {
|
||||||
|
DiffKind::SourceOnly
|
||||||
|
} else {
|
||||||
|
match (fs.read(&source), fs.read(&target)) {
|
||||||
|
(Ok(src), Ok(tgt)) if src == tgt => DiffKind::Identical,
|
||||||
|
(Ok(_), Ok(_)) => DiffKind::Modified,
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
results.push(DiffEntry {
|
||||||
|
source_display,
|
||||||
|
target,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_diff(package: &str, entries: &[DiffEntry]) {
|
||||||
|
let non_identical: Vec<_> = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.kind != DiffKind::Identical)
|
||||||
|
.collect();
|
||||||
|
if non_identical.is_empty() {
|
||||||
|
println!(" {package}: all copies identical");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
println!(" \x1b[1m{package}\x1b[0m:");
|
||||||
|
for entry in &non_identical {
|
||||||
|
let label = match entry.kind {
|
||||||
|
DiffKind::Modified => "\x1b[33mmodified\x1b[0m",
|
||||||
|
DiffKind::SourceOnly => "\x1b[36msource only\x1b[0m",
|
||||||
|
DiffKind::TargetOnly => "\x1b[35mtarget only\x1b[0m",
|
||||||
|
DiffKind::Identical => unreachable!(),
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {label} {} -> {}",
|
||||||
|
entry.source_display,
|
||||||
|
entry.target.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn print_status(package: &str, entries: &[StatusEntry]) {
|
pub fn print_status(package: &str, entries: &[StatusEntry]) {
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
println!(" {package}: (empty)");
|
println!(" {package}: (empty)");
|
||||||
@@ -136,3 +258,18 @@ pub fn print_status(package: &str, entries: &[StatusEntry]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn print_status_json(package: &str, entries: &[StatusEntry]) {
|
||||||
|
let items: Vec<_> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
json!({
|
||||||
|
"source": e.source_display,
|
||||||
|
"target": e.target.to_string_lossy(),
|
||||||
|
"status": format!("{:?}", e.status).to_lowercase(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let obj = json!({"package": package, "entries": items});
|
||||||
|
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
|
||||||
|
}
|
||||||
|
|||||||
23
crates/notfiles/tests/fixtures/dotfiles/README.md
vendored
Normal file
23
crates/notfiles/tests/fixtures/dotfiles/README.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Test Fixture — Dotfiles
|
||||||
|
|
||||||
|
This directory is a test fixture representing a realistic dotfiles repo.
|
||||||
|
It is used by notfiles integration tests and serves as an example of the
|
||||||
|
expected directory structure.
|
||||||
|
|
||||||
|
## Package directories (linked to ~)
|
||||||
|
|
||||||
|
- `git/` — `.gitconfig` at root
|
||||||
|
- `zsh/` — `.zshrc` at root
|
||||||
|
- `nushell/` — `.config/nushell/` tree
|
||||||
|
- `starship/` — `.config/starship.toml`
|
||||||
|
- `alacritty/` — `.config/alacritty/alacritty.toml`
|
||||||
|
- `mise/` — `.config/mise/config.toml`
|
||||||
|
- `fish/` — `.config/fish/config.fish`
|
||||||
|
- `nixos/` — platform-gated to Linux only
|
||||||
|
- `vscode/` — deep `Library/Application Support/` path (macOS)
|
||||||
|
|
||||||
|
## Non-package directories (excluded via include list)
|
||||||
|
|
||||||
|
- `scripts/` — shell scripts, not a stow target
|
||||||
|
- `docs/` — documentation
|
||||||
|
- `secrets/` — encrypted env files, copy-only with ignore
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/alacritty/.config/alacritty/alacritty.toml
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/alacritty/.config/alacritty/alacritty.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[font]
|
||||||
|
size = 14.0
|
||||||
3
crates/notfiles/tests/fixtures/dotfiles/docs/bootstrap-runbook.md
vendored
Normal file
3
crates/notfiles/tests/fixtures/dotfiles/docs/bootstrap-runbook.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Bootstrap Runbook
|
||||||
|
|
||||||
|
Steps for setting up a new machine.
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/fish/.config/fish/config.fish
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/fish/.config/fish/config.fish
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Example fish config
|
||||||
|
set -gx EDITOR nvim
|
||||||
5
crates/notfiles/tests/fixtures/dotfiles/git/.gitconfig
vendored
Normal file
5
crates/notfiles/tests/fixtures/dotfiles/git/.gitconfig
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[user]
|
||||||
|
name = Example User
|
||||||
|
email = user@example.com
|
||||||
|
[init]
|
||||||
|
defaultBranch = main
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/mise/.config/mise/config.toml
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/mise/.config/mise/config.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[tools]
|
||||||
|
node = "lts"
|
||||||
4
crates/notfiles/tests/fixtures/dotfiles/nixos/configuration.nix
vendored
Normal file
4
crates/notfiles/tests/fixtures/dotfiles/nixos/configuration.nix
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{ config, pkgs, ... }:
|
||||||
|
{
|
||||||
|
environment.systemPackages = with pkgs; [ vim git ];
|
||||||
|
}
|
||||||
34
crates/notfiles/tests/fixtures/dotfiles/notfiles.toml
vendored
Normal file
34
crates/notfiles/tests/fixtures/dotfiles/notfiles.toml
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Example notfiles.toml — mirrors a realistic dotfiles repo.
|
||||||
|
#
|
||||||
|
# This fixture is used by integration tests and serves as documentation
|
||||||
|
# for how to configure notfiles with a mixed-purpose dotfiles repo.
|
||||||
|
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfiles-state.toml", ".nothooks-state.toml"]
|
||||||
|
|
||||||
|
# Only link actual stow packages — skip operational dirs.
|
||||||
|
include = ["git", "zsh", "nushell", "starship", "alacritty", "mise", "fish", "nixos", "vscode"]
|
||||||
|
|
||||||
|
[packages.git]
|
||||||
|
|
||||||
|
[packages.zsh]
|
||||||
|
|
||||||
|
[packages.nushell]
|
||||||
|
|
||||||
|
[packages.starship]
|
||||||
|
|
||||||
|
[packages.alacritty]
|
||||||
|
|
||||||
|
[packages.mise]
|
||||||
|
|
||||||
|
[packages.fish]
|
||||||
|
|
||||||
|
[packages.nixos]
|
||||||
|
platforms = ["linux"]
|
||||||
|
|
||||||
|
[packages.vscode]
|
||||||
|
|
||||||
|
[packages.secrets]
|
||||||
|
method = "copy"
|
||||||
|
ignore = ["*.example"]
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/autoload/aliases.nu
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/autoload/aliases.nu
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Example aliases
|
||||||
|
alias ll = ls -l
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/config.nu
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/config.nu
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Example nushell config
|
||||||
|
$env.config.show_banner = false
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/env.nu
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/env.nu
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Example nushell env
|
||||||
|
$env.EDITOR = "nvim"
|
||||||
3
crates/notfiles/tests/fixtures/dotfiles/scripts/doctor.sh
vendored
Normal file
3
crates/notfiles/tests/fixtures/dotfiles/scripts/doctor.sh
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Health check script — NOT a stow package.
|
||||||
|
echo "All good"
|
||||||
3
crates/notfiles/tests/fixtures/dotfiles/scripts/lib/common.sh
vendored
Normal file
3
crates/notfiles/tests/fixtures/dotfiles/scripts/lib/common.sh
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared helpers — NOT a stow package.
|
||||||
|
log() { echo "[$(date)] $*"; }
|
||||||
3
crates/notfiles/tests/fixtures/dotfiles/secrets/.env.json.example
vendored
Normal file
3
crates/notfiles/tests/fixtures/dotfiles/secrets/.env.json.example
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"EXAMPLE_KEY": "replace-me"
|
||||||
|
}
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/starship/.config/starship.toml
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/starship/.config/starship.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[character]
|
||||||
|
success_symbol = "[>](bold green)"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"editor.fontSize": 14,
|
||||||
|
"editor.tabSize": 2
|
||||||
|
}
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/zsh/.zshrc
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/zsh/.zshrc
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Example zshrc
|
||||||
|
export EDITOR=nvim
|
||||||
@@ -463,3 +463,164 @@ fn test_unlink_cleans_empty_dirs() {
|
|||||||
// The .config/zsh directory should be cleaned up
|
// The .config/zsh directory should be cleaned up
|
||||||
assert!(!target.join(".config/zsh").exists());
|
assert!(!target.join(".config/zsh").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fixture-based tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Copy the static fixture into a temp dir with a rewritten target, so tests
|
||||||
|
/// don't touch the real home directory.
|
||||||
|
fn setup_from_fixture(tmp: &TempDir) {
|
||||||
|
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/dotfiles");
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
fs::create_dir_all(&target).unwrap();
|
||||||
|
copy_dir_recursive(&fixture, &dotfiles).unwrap();
|
||||||
|
|
||||||
|
// Rewrite notfiles.toml to point target at our temp home
|
||||||
|
let config_path = dotfiles.join("notfiles.toml");
|
||||||
|
let content = fs::read_to_string(&config_path).unwrap();
|
||||||
|
let rewritten = content.replace(
|
||||||
|
"target = \"~\"",
|
||||||
|
&format!("target = \"{}\"", target.display()),
|
||||||
|
);
|
||||||
|
fs::write(&config_path, rewritten).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
|
fs::create_dir_all(dst)?;
|
||||||
|
for entry in fs::read_dir(src)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let src_path = entry.path();
|
||||||
|
let dst_path = dst.join(entry.file_name());
|
||||||
|
if src_path.is_dir() {
|
||||||
|
copy_dir_recursive(&src_path, &dst_path)?;
|
||||||
|
} else {
|
||||||
|
fs::copy(&src_path, &dst_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_include_excludes_non_packages() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["link"]);
|
||||||
|
assert!(ok);
|
||||||
|
|
||||||
|
// Stow packages should be linked
|
||||||
|
assert!(
|
||||||
|
target.join(".gitconfig").exists(),
|
||||||
|
".gitconfig should be linked"
|
||||||
|
);
|
||||||
|
assert!(target.join(".zshrc").exists(), ".zshrc should be linked");
|
||||||
|
assert!(
|
||||||
|
target.join(".config/nushell/config.nu").exists(),
|
||||||
|
"nushell config should be linked",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
target.join(".config/starship.toml").exists(),
|
||||||
|
"starship config should be linked",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Non-package dirs should NOT be linked
|
||||||
|
assert!(
|
||||||
|
!target.join("lib/common.sh").exists(),
|
||||||
|
"scripts/ should not be linked",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!target.join("bootstrap-runbook.md").exists(),
|
||||||
|
"docs/ should not be linked",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!target.join("doctor.sh").exists(),
|
||||||
|
"scripts/ should not be linked",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_status_shows_all_packages() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
|
||||||
|
let (stdout, _, ok) = run(&dotfiles, &["status"]);
|
||||||
|
assert!(ok);
|
||||||
|
|
||||||
|
// Should list stow packages
|
||||||
|
assert!(stdout.contains("git"), "status should show git package");
|
||||||
|
assert!(stdout.contains("zsh"), "status should show zsh package");
|
||||||
|
assert!(
|
||||||
|
stdout.contains("nushell"),
|
||||||
|
"status should show nushell package"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should NOT list excluded dirs
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("scripts"),
|
||||||
|
"status should not show scripts"
|
||||||
|
);
|
||||||
|
assert!(!stdout.contains("docs"), "status should not show docs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_deep_paths_link_correctly() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["link", "vscode"]);
|
||||||
|
assert!(ok);
|
||||||
|
|
||||||
|
let vscode_settings = target.join("Library/Application Support/Code/User/settings.json");
|
||||||
|
assert!(
|
||||||
|
vscode_settings.exists(),
|
||||||
|
"deep path should be linked: {}",
|
||||||
|
vscode_settings.display(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_platform_filter_skips_linux_on_macos() {
|
||||||
|
if !cfg!(target_os = "macos") {
|
||||||
|
return; // This test is macOS-specific
|
||||||
|
}
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["link"]);
|
||||||
|
assert!(ok);
|
||||||
|
|
||||||
|
// nixos has platforms = ["linux"], so it should NOT be linked on macOS
|
||||||
|
assert!(
|
||||||
|
!target.join("configuration.nix").exists(),
|
||||||
|
"nixos package should be skipped on macOS",
|
||||||
|
);
|
||||||
|
|
||||||
|
// But macos-compatible packages should be linked
|
||||||
|
assert!(target.join(".gitconfig").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_link_and_unlink_round_trip() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
|
||||||
|
// Link all
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["link"]);
|
||||||
|
assert!(ok);
|
||||||
|
assert!(target.join(".gitconfig").exists());
|
||||||
|
|
||||||
|
// Unlink all
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["unlink"]);
|
||||||
|
assert!(ok);
|
||||||
|
assert!(!target.join(".gitconfig").exists());
|
||||||
|
assert!(!target.join(".zshrc").exists());
|
||||||
|
}
|
||||||
|
|||||||
BIN
crates/notgraph/.DS_Store
vendored
Normal file
BIN
crates/notgraph/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
crates/nothooks/.DS_Store
vendored
Normal file
BIN
crates/nothooks/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
crates/notsecrets/.DS_Store
vendored
Normal file
BIN
crates/notsecrets/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -14,7 +14,6 @@ serde = { workspace = true }
|
|||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
ed25519-dalek = "2"
|
ed25519-dalek = "2"
|
||||||
rsa = { version = "0.9", features = ["sha2"] }
|
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ pub use ssh_ed25519::SshEd25519Identity;
|
|||||||
pub mod scrypt;
|
pub mod scrypt;
|
||||||
pub use scrypt::ScryptIdentity;
|
pub use scrypt::ScryptIdentity;
|
||||||
|
|
||||||
pub mod ssh_rsa;
|
|
||||||
pub use ssh_rsa::SshRsaIdentity;
|
|
||||||
|
|
||||||
pub mod encrypted;
|
pub mod encrypted;
|
||||||
pub use encrypted::EncryptedIdentity;
|
pub use encrypted::EncryptedIdentity;
|
||||||
|
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
use crate::error::AgeError;
|
|
||||||
use crate::identities::{FileKey, Identity, Stanza};
|
|
||||||
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
|
||||||
use rsa::{Oaep, RsaPrivateKey, RsaPublicKey, traits::PublicKeyParts};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
const TAG: &str = "ssh-rsa";
|
|
||||||
|
|
||||||
pub struct SshRsaIdentity {
|
|
||||||
private_key: RsaPrivateKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SshRsaIdentity {
|
|
||||||
pub fn from_private_key(private_key: RsaPrivateKey) -> Self {
|
|
||||||
Self { private_key }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fingerprint(&self) -> [u8; 4] {
|
|
||||||
rsa_pubkey_fingerprint(&RsaPublicKey::from(&self.private_key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Identity for SshRsaIdentity {
|
|
||||||
fn unwrap_file_key(&self, stanza: &Stanza) -> Option<Result<FileKey, AgeError>> {
|
|
||||||
if stanza.tag != TAG {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if stanza.args.len() != 1 {
|
|
||||||
return Some(Err(AgeError::ParseError(
|
|
||||||
"ssh-rsa stanza must have 1 arg".to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let fp = match STANDARD_NO_PAD.decode(&stanza.args[0]) {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(e) => {
|
|
||||||
return Some(Err(AgeError::ParseError(format!(
|
|
||||||
"fingerprint base64: {e}"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if fp.as_slice() != self.fingerprint() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(unwrap(stanza, &self.private_key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unwrap(stanza: &Stanza, private_key: &RsaPrivateKey) -> Result<FileKey, AgeError> {
|
|
||||||
let padding = Oaep::new::<Sha256>();
|
|
||||||
let file_key_bytes = private_key
|
|
||||||
.decrypt(padding, &stanza.body)
|
|
||||||
.map_err(|_| AgeError::CryptoError("RSA-OAEP decrypt failed".to_string()))?;
|
|
||||||
FileKey::try_from(file_key_bytes.as_slice())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn rsa_pubkey_fingerprint(public_key: &RsaPublicKey) -> [u8; 4] {
|
|
||||||
let n_bytes = public_key.n().to_bytes_be();
|
|
||||||
let e_bytes = public_key.e().to_bytes_be();
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(&n_bytes);
|
|
||||||
hasher.update(&e_bytes);
|
|
||||||
let hash = hasher.finalize();
|
|
||||||
hash[..4].try_into().expect("SHA-256 is 32 bytes")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::recipients::Recipient;
|
|
||||||
use crate::recipients::ssh_rsa::SshRsaRecipient;
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
|
|
||||||
fn test_rsa_keypair() -> (RsaPrivateKey, RsaPublicKey) {
|
|
||||||
let private_key = RsaPrivateKey::new(&mut OsRng, 2048).expect("RSA keygen failed");
|
|
||||||
let public_key = RsaPublicKey::from(&private_key);
|
|
||||||
(private_key, public_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ssh_rsa_wrap_unwrap_roundtrip() {
|
|
||||||
let (private_key, public_key) = test_rsa_keypair();
|
|
||||||
let recipient = SshRsaRecipient::from_public_key(public_key);
|
|
||||||
let identity = SshRsaIdentity::from_private_key(private_key);
|
|
||||||
|
|
||||||
let file_key = FileKey::new([0x55u8; 16]);
|
|
||||||
let stanza = recipient
|
|
||||||
.wrap_file_key(&file_key)
|
|
||||||
.expect("wrap should succeed");
|
|
||||||
assert_eq!(stanza.tag, "ssh-rsa");
|
|
||||||
assert_eq!(stanza.args.len(), 1);
|
|
||||||
|
|
||||||
let unwrapped = identity
|
|
||||||
.unwrap_file_key(&stanza)
|
|
||||||
.expect("identity should match")
|
|
||||||
.expect("unwrap should succeed");
|
|
||||||
assert_eq!(unwrapped.as_bytes(), file_key.as_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ssh_rsa_wrong_fingerprint_returns_none() {
|
|
||||||
let (private_key1, _) = test_rsa_keypair();
|
|
||||||
let (_, public_key2) = test_rsa_keypair();
|
|
||||||
let recipient = SshRsaRecipient::from_public_key(public_key2);
|
|
||||||
let identity = SshRsaIdentity::from_private_key(private_key1);
|
|
||||||
|
|
||||||
let file_key = FileKey::new([0x55u8; 16]);
|
|
||||||
let stanza = recipient.wrap_file_key(&file_key).unwrap();
|
|
||||||
assert!(identity.unwrap_file_key(&stanza).is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,13 +15,11 @@ pub use encrypt::Encryptor;
|
|||||||
pub use error::AgeError;
|
pub use error::AgeError;
|
||||||
pub use error::SecretsError;
|
pub use error::SecretsError;
|
||||||
pub use identities::{
|
pub use identities::{
|
||||||
EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity,
|
EncryptedIdentity, FileKey, Header, Identity, ScryptIdentity, SshEd25519Identity, Stanza,
|
||||||
SshRsaIdentity, Stanza, X25519Identity,
|
X25519Identity,
|
||||||
};
|
};
|
||||||
pub use ports::{EnumerableSecretSource, IdentitySource, SecretSource};
|
pub use ports::{EnumerableSecretSource, IdentitySource, SecretSource};
|
||||||
pub use recipients::{
|
pub use recipients::{Recipient, ScryptRecipient, SshEd25519Recipient, X25519Recipient};
|
||||||
Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient, X25519Recipient,
|
|
||||||
};
|
|
||||||
pub use resolver::SecretResolver;
|
pub use resolver::SecretResolver;
|
||||||
pub use sources::{
|
pub use sources::{
|
||||||
BitwardenSource, DirenvSource, DotenvxSource, DotenvySource, EnvSource, FileSource, GsmSource,
|
BitwardenSource, DirenvSource, DotenvxSource, DotenvySource, EnvSource, FileSource, GsmSource,
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ pub use ssh_ed25519::SshEd25519Recipient;
|
|||||||
pub mod scrypt;
|
pub mod scrypt;
|
||||||
pub use scrypt::ScryptRecipient;
|
pub use scrypt::ScryptRecipient;
|
||||||
|
|
||||||
pub mod ssh_rsa;
|
|
||||||
pub use ssh_rsa::SshRsaRecipient;
|
|
||||||
|
|
||||||
/// Domain port: a recipient that can wrap a file key into a stanza.
|
/// Domain port: a recipient that can wrap a file key into a stanza.
|
||||||
pub trait Recipient {
|
pub trait Recipient {
|
||||||
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError>;
|
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError>;
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
use crate::error::AgeError;
|
|
||||||
use crate::identities::ssh_rsa::rsa_pubkey_fingerprint;
|
|
||||||
use crate::identities::{FileKey, Stanza};
|
|
||||||
use crate::recipients::Recipient;
|
|
||||||
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
|
||||||
use rsa::{Oaep, RsaPublicKey};
|
|
||||||
use sha2::Sha256;
|
|
||||||
|
|
||||||
const TAG: &str = "ssh-rsa";
|
|
||||||
|
|
||||||
pub struct SshRsaRecipient {
|
|
||||||
public_key: RsaPublicKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SshRsaRecipient {
|
|
||||||
pub fn from_public_key(public_key: RsaPublicKey) -> Self {
|
|
||||||
Self { public_key }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Recipient for SshRsaRecipient {
|
|
||||||
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError> {
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
let padding = Oaep::new::<Sha256>();
|
|
||||||
let wrapped = self
|
|
||||||
.public_key
|
|
||||||
.encrypt(&mut OsRng, padding, file_key.as_bytes())
|
|
||||||
.map_err(|_| AgeError::CryptoError("RSA-OAEP encrypt failed".to_string()))?;
|
|
||||||
|
|
||||||
let fingerprint = rsa_pubkey_fingerprint(&self.public_key);
|
|
||||||
|
|
||||||
Ok(Stanza {
|
|
||||||
tag: TAG.to_string(),
|
|
||||||
args: vec![STANDARD_NO_PAD.encode(fingerprint)],
|
|
||||||
body: wrapped,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
docs/superpowers/.DS_Store
vendored
Normal file
BIN
docs/superpowers/.DS_Store
vendored
Normal file
Binary file not shown.
7
notfiles.toml
Normal file
7
notfiles.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
ignore = [".git", ".DS_Store", "README.md", "LICENSE*", "notfiles.toml", ".notfiles-state.toml", ".nothooks-state.toml", "Cargo.toml", "Cargo.lock", "crates", "docs", "tests", "target", "scripts", "mise.toml", "deny.toml", "rustqual.toml", ".github", ".gitea", ".envrc", ".sccignore", ".codex", ".superpowers", ".remember", ".worktrees", ".ctx", "HANDOFF.md", "AGENTS.md", "preflight.md"]
|
||||||
|
|
||||||
|
include = ["nushell"]
|
||||||
|
|
||||||
|
[packages.nushell]
|
||||||
BIN
nushell/.DS_Store
vendored
Normal file
BIN
nushell/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
nushell/.config/.DS_Store
vendored
Normal file
BIN
nushell/.config/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
nushell/.config/nushell/.DS_Store
vendored
Normal file
BIN
nushell/.config/nushell/.DS_Store
vendored
Normal file
Binary file not shown.
99
rustqual.toml
Normal file
99
rustqual.toml
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# rustqual.toml — Tailored configuration for your project
|
||||||
|
# Generated from analysis of 87 file(s), 488 function(s).
|
||||||
|
#
|
||||||
|
# Thresholds are set to your current maximums + 20% headroom.
|
||||||
|
# Tighten them over time as you improve code quality.
|
||||||
|
|
||||||
|
# ── Function Classification ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
ignore_functions = ["main", "test_*"]
|
||||||
|
exclude_files = [
|
||||||
|
"crates/notgraph/tests/*",
|
||||||
|
"scripts/preflight.rs",
|
||||||
|
]
|
||||||
|
strict_closures = false
|
||||||
|
strict_iterator_chains = false
|
||||||
|
allow_recursion = false
|
||||||
|
strict_error_propagation = false
|
||||||
|
|
||||||
|
# ── Suppression Health ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
max_suppression_ratio = 0.05
|
||||||
|
fail_on_warnings = false
|
||||||
|
|
||||||
|
# ── Complexity Analysis ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[complexity]
|
||||||
|
enabled = true
|
||||||
|
max_cognitive = 72 # current max: 60
|
||||||
|
max_cyclomatic = 30 # current max: 25
|
||||||
|
max_nesting_depth = 9 # current max: 7
|
||||||
|
max_function_lines = 286 # current max: 238
|
||||||
|
include_nesting_penalty = true
|
||||||
|
detect_magic_numbers = true
|
||||||
|
detect_unsafe = true
|
||||||
|
detect_error_handling = true
|
||||||
|
allowed_magic_numbers = ["0", "1", "-1", "2"]
|
||||||
|
|
||||||
|
# ── DRY / Duplicate Detection ───────────────────────────────────────────
|
||||||
|
|
||||||
|
[duplicates]
|
||||||
|
enabled = true
|
||||||
|
similarity_threshold = 0.85
|
||||||
|
min_tokens = 30
|
||||||
|
min_lines = 5
|
||||||
|
min_statements = 3
|
||||||
|
ignore_tests = true
|
||||||
|
ignore_trait_impls = true
|
||||||
|
detect_dead_code = false
|
||||||
|
detect_wildcard_imports = true
|
||||||
|
detect_repeated_matches = true
|
||||||
|
|
||||||
|
# ── Boilerplate Detection ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[boilerplate]
|
||||||
|
enabled = true
|
||||||
|
suggest_crates = true
|
||||||
|
|
||||||
|
# ── SRP (Single Responsibility) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
[srp]
|
||||||
|
enabled = true
|
||||||
|
smell_threshold = 0.6
|
||||||
|
max_fields = 12
|
||||||
|
max_methods = 20
|
||||||
|
max_fan_out = 10
|
||||||
|
lcom4_threshold = 2
|
||||||
|
weights = [0.4, 0.25, 0.15, 0.2]
|
||||||
|
file_length_baseline = 300
|
||||||
|
file_length_ceiling = 800
|
||||||
|
max_independent_clusters = 2
|
||||||
|
min_cluster_statements = 5
|
||||||
|
max_parameters = 5
|
||||||
|
|
||||||
|
# ── Coupling Analysis ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[coupling]
|
||||||
|
enabled = true
|
||||||
|
max_instability = 0.8
|
||||||
|
max_fan_in = 15
|
||||||
|
max_fan_out = 12
|
||||||
|
check_sdp = true
|
||||||
|
|
||||||
|
# ── Test Quality Analysis ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[test_quality]
|
||||||
|
enabled = true
|
||||||
|
# coverage_file = "lcov.info"
|
||||||
|
|
||||||
|
# ── Quality Score Weights ──────────────────────────────────────────────
|
||||||
|
# Must sum to approximately 1.0.
|
||||||
|
|
||||||
|
[weights]
|
||||||
|
iosp = 0.22
|
||||||
|
complexity = 0.18
|
||||||
|
dry = 0.13
|
||||||
|
srp = 0.18
|
||||||
|
coupling = 0.09
|
||||||
|
test_quality = 0.10
|
||||||
|
architecture = 0.10
|
||||||
@@ -66,6 +66,133 @@ fn secret_resolver_resolves_env_var_cross_crate() {
|
|||||||
unsafe { std::env::remove_var("NOTFILES_CROSS_CRATE_TEST") };
|
unsafe { std::env::remove_var("NOTFILES_CROSS_CRATE_TEST") };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── nu_libs nushell package e2e ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Helper: build a dotfiles dir containing the nushell/nu_libs.nu package
|
||||||
|
/// mirroring the real notfiles repo layout.
|
||||||
|
fn make_nu_libs_dotfiles(dotfiles: &Path, target_home: &Path) {
|
||||||
|
let src = dotfiles.join("nushell/.config/nushell/autoload");
|
||||||
|
fs::create_dir_all(&src).unwrap();
|
||||||
|
fs::write(
|
||||||
|
src.join("nu_libs.nu"),
|
||||||
|
b"const NU_LIBS_AI = \"/Users/joe/dev/nu_libs/lib/ai/mod.nu\"\nuse $NU_LIBS_AI *\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
dotfiles.join("notfiles.toml"),
|
||||||
|
format!(
|
||||||
|
"[defaults]\ntarget = \"{}\"\ninclude = [\"nushell\"]\n\n[packages.nushell]\n",
|
||||||
|
target_home.display()
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The nu_libs autoload file is symlinked into the correct autoload directory.
|
||||||
|
#[test]
|
||||||
|
fn nu_libs_autoload_is_linked_to_target() {
|
||||||
|
let dotfiles = TempDir::new().unwrap();
|
||||||
|
let home = TempDir::new().unwrap();
|
||||||
|
let autoload = home.path().join(".config/nushell/autoload");
|
||||||
|
fs::create_dir_all(&autoload).unwrap();
|
||||||
|
|
||||||
|
make_nu_libs_dotfiles(dotfiles.path(), home.path());
|
||||||
|
|
||||||
|
let opts = LinkOptions {
|
||||||
|
force: false,
|
||||||
|
no_backup: false,
|
||||||
|
dry_run: false,
|
||||||
|
verbose: false,
|
||||||
|
};
|
||||||
|
link(dotfiles.path(), &["nushell".to_string()], &opts).unwrap();
|
||||||
|
|
||||||
|
let link_path = autoload.join("nu_libs.nu");
|
||||||
|
assert!(link_path.exists(), "nu_libs.nu should exist at target");
|
||||||
|
assert!(link_path.is_symlink(), "nu_libs.nu should be a symlink");
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_link(&link_path).unwrap(),
|
||||||
|
dotfiles
|
||||||
|
.path()
|
||||||
|
.join("nushell/.config/nushell/autoload/nu_libs.nu"),
|
||||||
|
"symlink should point into the notfiles nushell package"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlinking removes the symlink but leaves the autoload directory intact.
|
||||||
|
#[test]
|
||||||
|
fn nu_libs_autoload_is_removed_on_unlink() {
|
||||||
|
let dotfiles = TempDir::new().unwrap();
|
||||||
|
let home = TempDir::new().unwrap();
|
||||||
|
let autoload = home.path().join(".config/nushell/autoload");
|
||||||
|
fs::create_dir_all(&autoload).unwrap();
|
||||||
|
|
||||||
|
make_nu_libs_dotfiles(dotfiles.path(), home.path());
|
||||||
|
|
||||||
|
let opts = LinkOptions {
|
||||||
|
force: false,
|
||||||
|
no_backup: false,
|
||||||
|
dry_run: false,
|
||||||
|
verbose: false,
|
||||||
|
};
|
||||||
|
link(dotfiles.path(), &["nushell".to_string()], &opts).unwrap();
|
||||||
|
notfiles::unlink(dotfiles.path(), &["nushell".to_string()], &opts).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!autoload.join("nu_libs.nu").exists(),
|
||||||
|
"symlink should be removed after unlink"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pre-existing regular file at the target path causes link to fail without --force.
|
||||||
|
#[test]
|
||||||
|
fn nu_libs_link_does_not_overwrite_existing_file_without_force() {
|
||||||
|
let dotfiles = TempDir::new().unwrap();
|
||||||
|
let home = TempDir::new().unwrap();
|
||||||
|
let autoload = home.path().join(".config/nushell/autoload");
|
||||||
|
fs::create_dir_all(&autoload).unwrap();
|
||||||
|
fs::write(autoload.join("nu_libs.nu"), b"# existing\n").unwrap();
|
||||||
|
|
||||||
|
make_nu_libs_dotfiles(dotfiles.path(), home.path());
|
||||||
|
|
||||||
|
let opts = LinkOptions {
|
||||||
|
force: false,
|
||||||
|
no_backup: false,
|
||||||
|
dry_run: false,
|
||||||
|
verbose: false,
|
||||||
|
};
|
||||||
|
let result = link(dotfiles.path(), &["nushell".to_string()], &opts);
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"link should fail when target file exists without --force"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With --force the existing file is replaced by a symlink.
|
||||||
|
#[test]
|
||||||
|
fn nu_libs_link_with_force_replaces_existing_file() {
|
||||||
|
let dotfiles = TempDir::new().unwrap();
|
||||||
|
let home = TempDir::new().unwrap();
|
||||||
|
let autoload = home.path().join(".config/nushell/autoload");
|
||||||
|
fs::create_dir_all(&autoload).unwrap();
|
||||||
|
fs::write(autoload.join("nu_libs.nu"), b"# existing\n").unwrap();
|
||||||
|
|
||||||
|
make_nu_libs_dotfiles(dotfiles.path(), home.path());
|
||||||
|
|
||||||
|
let opts = LinkOptions {
|
||||||
|
force: true,
|
||||||
|
no_backup: false,
|
||||||
|
dry_run: false,
|
||||||
|
verbose: false,
|
||||||
|
};
|
||||||
|
link(dotfiles.path(), &["nushell".to_string()], &opts).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
autoload.join("nu_libs.nu").is_symlink(),
|
||||||
|
"nu_libs.nu should be a symlink after forced link"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Test that notfiles ignores .notfiles-state.toml and .nothooks-state.toml
|
/// Test that notfiles ignores .notfiles-state.toml and .nothooks-state.toml
|
||||||
/// by default — they must not be symlinked into the target directory and must
|
/// by default — they must not be symlinked into the target directory and must
|
||||||
/// not appear in the returned State.
|
/// not appear in the returned State.
|
||||||
|
|||||||
Reference in New Issue
Block a user