Merge pull request #20 from 89jobrien/chore/sort-cargo-manifests
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled

chore(manifests): sort Cargo.toml dependencies
This commit is contained in:
Joseph O'Brien
2026-07-11 23:49:24 -04:00
committed by GitHub
164 changed files with 18919 additions and 472 deletions

25
.claude/settings.json Normal file
View File

@@ -0,0 +1,25 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "nu /Users/joe/dev/notfiles/scripts/preflight.nu"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'if [ -f Cargo.toml ]; then cargo check --quiet 2>&1 | head -20; fi'"
}
]
}
]
}
}

View 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: $?\")"
]
}
}

Submodule .claude/worktrees/agent-a0b9834e added at 11d329c5a2

Submodule .claude/worktrees/agent-a27fe5f2 added at 0d60583c65

Submodule .claude/worktrees/agent-a6b81d3b added at 11d329c5a2

Submodule .claude/worktrees/agent-af9e1ed8 added at 11d329c5a2

25
.codex/hooks.json Normal file
View 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"
}
]
}
]
}
}

16
.ctx/HANDOFF.md Normal file
View File

@@ -0,0 +1,16 @@
# Handoff — notfiles (2026-07-07)
**Branch:** main | **Build:** unknown | **Tests:** unknown
## Items
| ID | P | Status | Title |
|---|---|---|---|
| uncommitted-work | P1 | open | Uncommitted changes (3 files) |
## 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-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

View File

@@ -0,0 +1,10 @@
updated: 2026-04-17
branch: main
build: clean
tests: passing
notes: Added notnet crate with YubikeySource and threaded Tailscale into notstrap
touched_files:
- .ctx/HANDOFF.notfiles.notfiles.yaml
- Cargo.toml
- crates/notnet/src/lib.rs
- crates/notstrap/src/lib.rs

View File

@@ -0,0 +1,39 @@
project: notfiles
id: notfile
updated: 2026-07-08
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:
- date: 2026-07-08
summary: hexagonal architecture refactor with adapters and integration tests, notsecrets cleanup, config payload moved to notfiles-config repo
commits:
- 6427d18
- 25624c9
- date: '20260606.135918'
summary: done=13 running=0 pending=0 blocked=0
commits: []
- date: 2026-04-17
summary: Added notnet crate with YubikeySource and threaded Tailscale into notstrap; design spec complete
commits: []
- date: 2026-04-15
summary: Hardened bootstrap and hook-state failure handling, made notfiles FileStore usage real, and fixed copy-drift safety coverage
commits: []
- date: 2026-04-15
summary: Pruned completed items from handoff; GitHub issues plus doob are now the source of truth for active work
commits: []

View File

@@ -108,9 +108,9 @@ jobs:
- name: Public readiness summary
if: success()
run: |
echo " No secrets or PII detected"
echo " No hardcoded private addresses"
echo " Licenses are OSS-compatible"
echo " No secrets files tracked in git"
echo "[OK] No secrets or PII detected"
echo "[OK] No hardcoded private addresses"
echo "[OK] Licenses are OSS-compatible"
echo "[OK] No secrets files tracked in git"
echo ""
echo "This repository appears safe to mirror to a public GitHub repo."

70
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
name: CI
on:
push:
branches: [main, "feature/*", "hotfix/*", "chore/*"]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
jobs:
compile-check:
name: Compile, Format & Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo check --workspace
- run: cargo fmt --all --check
- run: cargo clippy --workspace -- -D warnings
test:
name: Test
runs-on: ubuntu-latest
needs: compile-check
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: hustcer/setup-nu@v3
with:
version: 0.112.2
- uses: taiki-e/install-action@v2
with:
tool: cargo-nextest
- run: cargo nextest run --workspace
audit:
name: Audit & Deny
runs-on: ubuntu-latest
needs: compile-check
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/feature/') || startsWith(github.ref, 'refs/heads/hotfix/')
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: EmbarkStudios/cargo-deny-action@v2
- uses: taiki-e/install-action@v2
with:
tool: cargo-audit
- run: cargo audit
machete:
name: Unused Dependencies
runs-on: ubuntu-latest
needs: compile-check
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/feature/') || startsWith(github.ref, 'refs/heads/hotfix/')
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: cargo-machete
- run: cargo machete

28
.github/workflows/nightly.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Nightly
on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
geiger:
name: Unsafe Audit (geiger)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo install cargo-geiger --locked
- run: cargo geiger --workspace 2>&1 | tee geiger-report.txt
- run: |
echo "## Geiger Unsafe Audit" >> $GITHUB_STEP_SUMMARY
tee -a $GITHUB_STEP_SUMMARY < geiger-report.txt
- uses: actions/upload-artifact@v4
with:
name: geiger-report
path: geiger-report.txt
retention-days: 30

198
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,198 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: 'Semver version to apply to affected crates, without leading v'
required: true
type: string
env:
CARGO_TERM_COLOR: always
jobs:
release:
name: Bump, Tag, and Release
runs-on: ubuntu-latest
if: github.ref_name == 'main'
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Install tooling
uses: taiki-e/install-action@v2
with:
tool: cargo-edit,cargo-nextest
- name: Validate version input
env:
VERSION: ${{ inputs.version }}
run: |
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$'; then
echo "invalid semver: $VERSION" >&2
exit 1
fi
if git rev-parse "v$VERSION" >/dev/null 2>&1 || git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null 2>&1; then
echo "tag v$VERSION already exists" >&2
exit 1
fi
- name: Determine affected crates since last release
id: affected-crates
shell: bash
run: |
set -euo pipefail
if last_tag=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null); then
range="$last_tag..HEAD"
else
range="HEAD"
fi
if [ "$range" = "HEAD" ]; then
git ls-files > changed-files.txt
else
git diff --name-only "$range" > changed-files.txt
fi
cargo metadata --no-deps --format-version 1 > metadata.json
python3 <<'PY'
import json
from collections import defaultdict, deque
from pathlib import Path
changed_files = [line.strip() for line in Path("changed-files.txt").read_text().splitlines() if line.strip()]
metadata = json.loads(Path("metadata.json").read_text())
workspace_ids = set(metadata["workspace_members"])
packages = [pkg for pkg in metadata["packages"] if pkg["id"] in workspace_ids]
package_dirs = {}
reverse_deps = defaultdict(set)
package_names = set()
for pkg in packages:
name = pkg["name"]
package_names.add(name)
package_dirs[name] = Path(pkg["manifest_path"]).parent.as_posix()
for pkg in packages:
name = pkg["name"]
for dep in pkg.get("dependencies", []):
dep_name = dep.get("name")
if dep_name in package_names:
reverse_deps[dep_name].add(name)
direct = set()
shared_change = False
for changed in changed_files:
matched = False
for name, pkg_dir in package_dirs.items():
if changed == pkg_dir or changed.startswith(pkg_dir + "/"):
direct.add(name)
matched = True
if not matched and not changed.startswith('.github/workflows/release.yml'):
shared_change = True
if shared_change:
direct.update(package_names)
affected = set(direct)
queue = deque(direct)
while queue:
current = queue.popleft()
for dependent in reverse_deps.get(current, ()):
if dependent not in affected:
affected.add(dependent)
queue.append(dependent)
ignored = {"integration"}
affected = sorted(name for name in affected if name not in ignored)
if not affected:
raise SystemExit("No affected crates detected since the last release tag.")
Path("affected-crates.txt").write_text("\n".join(affected) + "\n")
PY
{
echo 'crates<<EOF'
cat affected-crates.txt
echo 'EOF'
} >> "$GITHUB_OUTPUT"
{
echo '### Affected crates'
sed 's/^/- `/' affected-crates.txt | sed 's/$/`/'
} >> "$GITHUB_STEP_SUMMARY"
- name: Run release gates
run: |
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo nextest run --workspace
- name: Bump affected crate versions
env:
VERSION: ${{ inputs.version }}
AFFECTED_CRATES: ${{ steps.affected-crates.outputs.crates }}
shell: bash
run: |
set -euo pipefail
while IFS= read -r crate; do
[ -n "$crate" ] || continue
cargo set-version -p "$crate" "$VERSION"
done <<< "$AFFECTED_CRATES"
- name: Commit version bump and create tag
env:
VERSION: ${{ inputs.version }}
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add crates/*/Cargo.toml Cargo.lock
git commit -m "release: v$VERSION"
git tag "v$VERSION"
- name: Push commit and tag to GitHub
env:
VERSION: ${{ inputs.version }}
run: |
git push origin HEAD:main
git push origin "v$VERSION"
- name: Build release binaries
env:
VERSION: ${{ inputs.version }}
run: |
cargo build --release -p notfiles -p nothooks -p notstrap -p notgraph
mkdir -p dist/notfiles-v$VERSION-linux-x86_64
cp target/release/notfiles dist/notfiles-v$VERSION-linux-x86_64/
cp target/release/nothooks dist/notfiles-v$VERSION-linux-x86_64/
cp target/release/notstrap dist/notfiles-v$VERSION-linux-x86_64/
cp target/release/notgraph dist/notfiles-v$VERSION-linux-x86_64/
tar -C dist -czf "notfiles-v$VERSION-linux-x86_64.tar.gz" "notfiles-v$VERSION-linux-x86_64"
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ inputs.version }}
target_commitish: main
files: notfiles-v${{ inputs.version }}-linux-x86_64.tar.gz
generate_release_notes: true
fail_on_unmatched_files: true

22
.gitignore vendored
View File

@@ -1,4 +1,26 @@
/target
.DS_Store
.pytest_cache/
.ruff_cache/
.venv/
.envrc
.worktrees/
.claude.local.md
docs/*
!docs/superpowers/
librust_out.rlib
.remember/
# handoff-begin
.ctx/*
!.ctx/HANDOFF.*.*.yaml
.ctx/HANDOFF.integration.notfiles.state.yaml
.ctx/HANDOFF.notcore.notfiles.state.yaml
.ctx/HANDOFF.notfiles.notfiles.state.yaml
.ctx/HANDOFF.notgraph.notfiles.state.yaml
.ctx/HANDOFF.nothooks.notfiles.state.yaml
.ctx/HANDOFF.notsecrets.notfiles.state.yaml
.ctx/HANDOFF.notstrap.notfiles.state.yaml
.ctx/.initialized
# handoff-end

76
.notfiles-state.toml Normal file
View 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
View File

@@ -0,0 +1,11 @@
.ctx
.kgx
.github
.claude
docs
examples
fuzz
target
*.md
*.json
tests/

View File

@@ -0,0 +1,71 @@
<h2>notfiles + Tailscale — Bootstrap Architecture</h2>
<p class="subtitle">How the crates fit together for ephemeral machine setup</p>
<div class="section">
<div style="background:#1a1a2e;border-radius:12px;padding:24px;font-family:monospace;font-size:13px;line-height:2;color:#e0e0e0;">
<div style="display:flex;gap:12px;align-items:flex-start;flex-wrap:wrap;">
<!-- Fresh machine -->
<div style="border:2px solid #4a9eff;border-radius:8px;padding:16px;min-width:160px;background:#0d1b2a;">
<div style="color:#4a9eff;font-weight:bold;margin-bottom:8px;">New Machine</div>
<div style="color:#aaa;font-size:12px;">TS_AUTHKEY env var</div>
<div style="color:#aaa;font-size:12px;">YubiKey (PIV slot 9d)</div>
<div style="color:#aaa;font-size:12px;">→ interactive prompt</div>
</div>
<div style="color:#888;font-size:20px;margin-top:28px;"></div>
<!-- notstrap -->
<div style="border:2px solid #f0c040;border-radius:8px;padding:16px;min-width:160px;background:#1a1500;">
<div style="color:#f0c040;font-weight:bold;margin-bottom:8px;">notstrap</div>
<div style="color:#aaa;font-size:12px;">1. prereqs check</div>
<div style="color:#aaa;font-size:12px;">2. → <span style="color:#ff7eb3">notnet</span></div>
<div style="color:#aaa;font-size:12px;">3. clone Gitea</div>
<div style="color:#aaa;font-size:12px;">4. → <span style="color:#7effa0">notsecrets</span></div>
<div style="color:#aaa;font-size:12px;">5. link dotfiles</div>
<div style="color:#aaa;font-size:12px;">6. run hooks</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:0;">
<div style="display:flex;align-items:center;gap:8px;">
<div style="color:#888;font-size:20px;"></div>
<!-- notnet -->
<div style="border:2px solid #ff7eb3;border-radius:8px;padding:16px;min-width:160px;background:#1a0a14;">
<div style="color:#ff7eb3;font-weight:bold;margin-bottom:8px;">notnet <span style="color:#666;font-size:10px;">NEW</span></div>
<div style="color:#aaa;font-size:12px;">detect existing Tailscale</div>
<div style="color:#aaa;font-size:12px;">install if missing</div>
<div style="color:#aaa;font-size:12px;">resolve auth key</div>
<div style="color:#aaa;font-size:12px;">join tailnet</div>
<div style="color:#aaa;font-size:12px;">verify peer reachable</div>
</div>
<div style="color:#888;font-size:20px;"></div>
<div style="border:2px solid #7effa0;border-radius:8px;padding:12px;min-width:120px;background:#001a08;">
<div style="color:#7effa0;font-weight:bold;margin-bottom:6px;">Tailnet</div>
<div style="color:#aaa;font-size:12px;">minibox (VPS)</div>
<div style="color:#aaa;font-size:12px;">Gitea repo</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<div style="color:#888;font-size:20px;"></div>
<!-- notsecrets -->
<div style="border:2px solid #7effa0;border-radius:8px;padding:16px;min-width:160px;background:#001a08;">
<div style="color:#7effa0;font-weight:bold;margin-bottom:8px;">notsecrets <span style="color:#666;font-size:10px;">+YubiKey</span></div>
<div style="color:#aaa;font-size:12px;">YubikeySource (PIV 9c)</div>
<div style="color:#aaa;font-size:12px;">BitwardenSource</div>
<div style="color:#aaa;font-size:12px;">FileSource</div>
<div style="color:#aaa;font-size:12px;">PromptSource</div>
</div>
</div>
</div>
</div>
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #333;color:#888;font-size:11px;">
Already on Tailnet? notstrap detects active Tailscale → skips notnet entirely
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1776399095365}

View File

@@ -0,0 +1,3 @@
{"type":"server-started","port":63245,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:63245","screen_dir":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/content","state_dir":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/state"}
{"type":"screen-added","file":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/content/architecture.html"}
{"type":"server-stopped","reason":"idle timeout"}

View File

@@ -0,0 +1 @@
72766

110
AGENTS.md Normal file
View 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.

View File

@@ -22,34 +22,64 @@ cargo fmt --check # check formatting
## Workspace Structure
This is a Cargo workspace with 5 crates under `crates/`:
This is a Cargo workspace with 7 crates under `crates/`:
| Crate | Purpose |
|-------|---------|
| `notcore` | Shared types: `Config`, `NotfilesError`, `expand_tilde`, `HookPhase`, `HookSpec`, `Report`, `StepStatus` |
| `notfiles` | Dotfiles linker — lib + `notfiles` binary (symlink/copy, init, status) |
| `notsecrets` | Age key retrieval via Bitwarden, file, or prompt; SOPS decryption |
| ------------ | -------------------------------------------------------------------------- |
| `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)
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/`:
- **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.
- **config** — Parses `notfiles.toml`. Per-package overrides for method, target, ignore. Falls back to `[defaults]`.
- **package** — Discovers packages (non-hidden subdirs) and recursively collects files via `IgnoreMatcher`.
- **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.
- **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
`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
@@ -61,7 +91,14 @@ Orchestrates in order: prereqs check → load config → clone dotfiles → age
## 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

1416
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,10 @@
[workspace.package]
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]
members = [
@@ -7,20 +12,29 @@ members = [
"crates/notfiles",
"crates/notsecrets",
"crates/nothooks",
"crates/notnet",
"crates/notstrap",
"crates/notgraph",
"crates/notforge",
"tests/integration",
]
resolver = "2"
[workspace.dependencies]
anyhow = "1"
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
dirs = "6"
globset = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
strsim = "0.11"
thiserror = "2"
toml = "0.8"
miette = { version = "7", features = ["derive"] }
ureq = { version = "2", features = ["json"] }
which = "7"
rpassword = "7"
tempfile = "3"
@@ -29,6 +43,8 @@ notcore = { path = "crates/notcore" }
notfiles = { path = "crates/notfiles" }
notsecrets = { path = "crates/notsecrets" }
nothooks = { path = "crates/nothooks" }
notnet = { path = "crates/notnet" }
notforge = { path = "crates/notforge" }
[profile.release]
opt-level = 3

View File

@@ -1,37 +1,24 @@
# HANDOFF.md
State of the `notfiles` repo as of 2026-03-31.
Legacy note as of 2026-04-15.
## What Was Done
This repo no longer uses `HANDOFF.md` as a running history log.
Restructured from a flat `src/` layout into a Cargo workspace of 5 crates:
- `notcore` — shared library (types, config, paths, errors)
- `notfiles` — dotfiles linker, migrated from `src/`
- `notsecrets` — age key retrieval (Bitwarden/file/prompt) + SOPS decrypt
- `nothooks` — Nushell hook runner with phase/state tracking
- `notstrap` — new-machine bootstrap orchestrator
Active workflow:
39 tests passing, 0 clippy warnings. Committed to `main`, not yet pushed to `gitea/main`.
1. Sync GitHub issues into `doob`
2. Read short-lived handoff context from `.ctx/HANDOFF.*.*.yaml`
3. Update issue state in `doob`
4. Sync `doob` back to GitHub issues
## Pending Issues
Rules:
### 1. Push to remote
```bash
git push
```
8 commits ahead of `gitea/main`. Nothing blocking this.
- Handoff context should be brief and ephemeral
- Only open work should appear in handoff files
- Closed issues should be removed from handoff files after sync
- Historical status belongs in GitHub issues and git history, not here
### 2. notstrap has an empty lib.rs
`crates/notstrap/src/lib.rs` is empty — created as a stub. `notstrap` is binary-only so this is harmless, but it's dead weight. Either delete `[lib]` from `notstrap/Cargo.toml` (if there is one) or remove the file.
Current state:
### 3. notstrap config not documented
`notstrap.toml` format is defined inline in `notstrap/src/main.rs` via serde structs but never written down. A sample `notstrap.toml` should be created and documented.
### 4. notsecrets has no binary
`notsecrets` is library-only. A small `notsecrets get-key` CLI binary could be useful for manual debugging/testing of the key retrieval chain, but not blocking.
### 5. Integration test coverage for nothooks assumes nu in PATH
`nothooks` integration tests call `nu` directly. If `nu` is not on PATH (e.g. on CI), the success/failure tests will fail for the wrong reason. CI should either install `nu` or the tests should be marked `#[ignore]` with a feature flag.
### 6. notstrap sops_file path is always joined to dotfiles_dir
If `sops_file` in `notstrap.toml` is an absolute path, the join will silently override the base. This is a minor footgun in `repo.clone_if_missing` usage — low priority.
- No open handoff items are tracked locally
- Use GitHub issues plus `doob` as the task source of truth

27
Justfile Normal file
View File

@@ -0,0 +1,27 @@
# Launch zellij workspace layout
workspace:
zellij --layout notfiles
pre-commit:
cargo fmt --all --check
cargo clippy --workspace -- -D warnings
prepush:
cargo nextest run --workspace
ci:
cargo fmt --all --check
cargo clippy --workspace -- -D warnings
cargo nextest run --workspace
install:
cargo install --path crates/notfiles --root "${HOME}/.local" --force
install-hooks:
#!/usr/bin/env sh
printf '#!/bin/sh\njust pre-commit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
printf '#!/bin/sh\njust prepush\n' > .git/hooks/pre-push
chmod +x .git/hooks/pre-push
printf '#!/bin/sh\ncommit_regex="^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)((.+))?: .+"\nif ! grep -qE "$commit_regex" "$1"; then\n echo "warning: commit message does not follow conventional commits (non-blocking)"\nfi\n' > .git/hooks/commit-msg
chmod +x .git/hooks/commit-msg

View File

@@ -15,10 +15,24 @@ notfiles link
# Check 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
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):
```bash
@@ -33,11 +47,13 @@ notstrap run
```
notfiles/
├── crates/
│ ├── notcore/ # shared types, config, paths, errors
│ ├── notcore/ # shared types, config, paths, errors, Reporter trait
│ ├── 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
── 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
└── notstrap.toml # bootstrap hook/phase config
```
@@ -46,11 +62,13 @@ notfiles/
| Crate | Type | Does |
|-------|------|------|
| `notcore` | lib | Shared types, config, paths, errors — no deps on other crates |
| `notfiles` | lib + bin | Symlink/copy packages into `$HOME`, track state |
| `notsecrets` | lib | Retrieve age key → sops decrypt → inject secrets |
| `notcore` | lib | Shared types, config, paths, errors, Reporter trait — no deps on other crates |
| `notfiles` | lib + bin | Symlink/copy packages into `$HOME`, track state, adopt/diff/check |
| `notsecrets` | lib | Multi-provider secret resolution, native age encryption/decryption |
| `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 |
| `notgraph` | lib + bin | Rust file dependency/import graph with HTML/Mermaid output |
### Dependency graph
@@ -60,6 +78,7 @@ notstrap
│ └── notcore
├── notsecrets
│ └── notcore
├── notnet
└── nothooks
└── notcore
```
@@ -91,13 +110,16 @@ State is tracked in `.notfiles-state.toml` so `unlink` and `status` know exactly
```
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
├─ collect_files() recursive walk, apply ignore patterns
├─ conflict_check() existing file? symlink to wrong target?
└─ linker::link_package() create symlinks (or copies), write state
└─ linker::link_package() create symlinks (or copies), write state,
return LinkResult with counters
```
### State file
@@ -156,16 +178,21 @@ Note: 1Password (`op`) is installed as a **hook** in phase `setup` — after sec
## Secrets Bootstrap Detail
`notsecrets` implements an `AgeKeySource` trait with three sources tried in order:
`notsecrets` implements a `SecretResolver` with pluggable provider sources:
```
AgeKeySource
├── BitwardenSource bw unlock → session token → bw get item age-key
├── FileSource read from --key-file path (USB, etc.)
── PromptSource read from stdin (paste)
SecretResolver
├── EnvSource read from environment variables
├── OpSource 1Password CLI (op read)
── 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]
method = "symlink"
target = "~"
include = ["git", "zsh", "nushell", "starship"] # allowlist (optional)
# exclude = ["scratch"] # or blocklist (mutually exclusive)
[packages.secrets]
method = "copy" # copy instead of symlink for sensitive files
[packages.nixos]
platforms = ["linux"] # only link on Linux
[packages.work]
target = "~/work" # different target dir
ignore = ["*.local"]

View File

@@ -3,10 +3,16 @@ name = "notcore"
version = "0.1.0"
edition = "2024"
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]
serde = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
dirs = { workspace = true }
anyhow = { workspace = true }
dirs = { workspace = true }
serde = { workspace = true }
strsim = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }

View File

@@ -1,3 +1,4 @@
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
@@ -18,6 +19,14 @@ pub struct Defaults {
pub target: String,
#[serde(default = "default_ignore")]
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 {
@@ -25,6 +34,8 @@ impl Default for Defaults {
Self {
target: default_target(),
ignore: default_ignore(),
include: Vec::new(),
exclude: Vec::new(),
}
}
}
@@ -52,6 +63,10 @@ pub struct PackageConfig {
pub target: Option<String>,
#[serde(default)]
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)]
@@ -71,16 +86,34 @@ impl std::fmt::Display for Method {
}
}
/// Load and deserialize a TOML config file, mapping I/O and parse errors
/// into the caller's crate-specific error type.
pub fn load_toml_file<T, E, ReadError, ParseError>(
path: &Path,
read_error: ReadError,
parse_error: ParseError,
) -> Result<T, E>
where
T: DeserializeOwned,
ReadError: FnOnce(&Path, std::io::Error) -> E,
ParseError: FnOnce(&Path, toml::de::Error) -> E,
{
let content = std::fs::read_to_string(path).map_err(|err| read_error(path, err))?;
toml::from_str(&content).map_err(|err| parse_error(path, err))
}
impl Config {
pub fn load(dotfiles_dir: &Path) -> Result<Self, NotfilesError> {
let config_path = dotfiles_dir.join("notfiles.toml");
if !config_path.exists() {
return Ok(Config::default());
}
let content = std::fs::read_to_string(&config_path)
.map_err(|e| NotfilesError::Config(format!("reading {}: {e}", config_path.display())))?;
let config: Config = toml::from_str(&content)
.map_err(|e| NotfilesError::Config(format!("parsing {}: {e}", config_path.display())))?;
let content = std::fs::read_to_string(&config_path).map_err(|e| {
NotfilesError::Config(format!("reading {}: {e}", config_path.display()))
})?;
let config: Config = toml::from_str(&content).map_err(|e| {
NotfilesError::Config(format!("parsing {}: {e}", config_path.display()))
})?;
Ok(config)
}
@@ -98,6 +131,50 @@ impl Config {
.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> {
let mut patterns: Vec<&str> = self.defaults.ignore.iter().map(|s| s.as_str()).collect();
if let Some(pkg) = self.packages.get(package) {
@@ -109,6 +186,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 {
r#"[defaults]
target = "~"
@@ -127,6 +221,10 @@ ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfil
mod tests {
use super::*;
fn temp_config_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("notcore-{name}-{}.toml", std::process::id()))
}
#[test]
fn test_default_config() {
let config = Config::default();
@@ -136,6 +234,92 @@ mod tests {
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]
fn test_parse_config() {
let toml_str = r#"
@@ -160,4 +344,40 @@ target = "~/bin"
assert!(ssh_ignores.contains(&".git"));
assert!(ssh_ignores.contains(&"known_hosts"));
}
#[test]
fn load_toml_file_deserializes_config() {
let path = temp_config_path("load-toml-file-ok");
std::fs::write(&path, "[defaults]\ntarget = \"~/dotfiles\"\n")
.expect("test should write temporary config");
let config: Config = load_toml_file(
&path,
|path, err| format!("read {}: {err}", path.display()),
|path, err| format!("parse {}: {err}", path.display()),
)
.expect("temporary config should parse");
assert_eq!(config.defaults.target, "~/dotfiles");
std::fs::remove_file(&path).expect("test should remove temporary config");
}
#[test]
fn load_toml_file_maps_parse_error() {
let path = temp_config_path("load-toml-file-parse-error");
std::fs::write(&path, "[defaults\ntarget = \"~/dotfiles\"\n")
.expect("test should write invalid temporary config");
let result: Result<Config, String> = load_toml_file(
&path,
|path, err| format!("read {}: {err}", path.display()),
|path, err| format!("parse {}: {err}", path.display()),
);
let err = result.expect_err("invalid TOML should return parse error");
assert!(err.contains("parse "));
std::fs::remove_file(&path).expect("test should remove temporary config");
}
}

View File

@@ -17,6 +17,12 @@ pub enum NotfilesError {
#[error("state file error: {0}")]
State(String),
#[error("glob error: {0}")]
Glob(String),
#[error("validation error: {0}")]
Validation(String),
#[error("{0}")]
Io(#[from] std::io::Error),

View File

@@ -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 error;
pub mod paths;
pub mod reporter;
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 paths::{dotfiles_dir, expand_tilde};
pub use reporter::{LinkEvent, Reporter, SilentReporter};
pub use types::{HookPhase, HookSpec, PackageSpec, Report, Step, StepStatus};

View 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<'_>) {}
}

View File

@@ -21,6 +21,8 @@ pub struct HookSpec {
pub name: String,
pub script: String,
pub phase: HookPhase,
#[serde(default)]
pub interpreter: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -48,7 +50,10 @@ pub struct Report {
impl Report {
pub fn add(&mut self, name: impl Into<String>, status: StepStatus) {
self.steps.push(Step { name: name.into(), status });
self.steps.push(Step {
name: name.into(),
status,
});
}
pub fn print(&self) {
@@ -67,7 +72,9 @@ impl Report {
}
pub fn has_failures(&self) -> bool {
self.steps.iter().any(|s| matches!(s.status, StepStatus::Failed(_)))
self.steps
.iter()
.any(|s| matches!(s.status, StepStatus::Failed(_)))
}
}

View File

@@ -3,6 +3,10 @@ name = "notfiles"
version = "0.1.0"
edition = "2024"
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"
[[bin]]
@@ -14,15 +18,17 @@ name = "notfiles"
path = "src/lib.rs"
[dependencies]
notcore = { workspace = true }
clap = { workspace = true }
globset = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true }
clap_complete = { workspace = true }
dirs = { workspace = true }
globset = { workspace = true }
notcore = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
assert_fs = { workspace = true }
tempfile = { workspace = true }

View File

@@ -0,0 +1,70 @@
use crate::ports::FileStore;
use std::path::{Path, PathBuf};
/// Standard file system adapter using std::fs.
pub struct FileStoreImpl;
impl FileStore for FileStoreImpl {
fn read(&self, path: &Path) -> Result<Vec<u8>, std::io::Error> {
std::fs::read(path)
}
fn read_to_string(&self, path: &Path) -> Result<String, std::io::Error> {
std::fs::read_to_string(path)
}
fn write(&self, path: &Path, contents: &[u8]) -> Result<(), std::io::Error> {
std::fs::write(path, contents)
}
fn rename(&self, from: &Path, to: &Path) -> Result<(), std::io::Error> {
std::fs::rename(from, to)
}
fn remove_file(&self, path: &Path) -> Result<(), std::io::Error> {
std::fs::remove_file(path)
}
fn remove_dir_all(&self, path: &Path) -> Result<(), std::io::Error> {
std::fs::remove_dir_all(path)
}
fn remove_dir(&self, path: &Path) -> Result<(), std::io::Error> {
std::fs::remove_dir(path)
}
fn read_link(&self, path: &Path) -> Result<PathBuf, std::io::Error> {
std::fs::read_link(path)
}
fn symlink_metadata(&self, path: &Path) -> Result<std::fs::Metadata, std::io::Error> {
std::fs::symlink_metadata(path)
}
fn metadata(&self, path: &Path) -> Result<std::fs::Metadata, std::io::Error> {
std::fs::metadata(path)
}
fn create_dir_all(&self, path: &Path) -> Result<(), std::io::Error> {
std::fs::create_dir_all(path)
}
fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, std::io::Error> {
std::fs::read_dir(path)?
.map(|entry| entry.map(|entry| entry.path()))
.collect()
}
#[cfg(unix)]
fn symlink(&self, target: &Path, link: &Path) -> Result<(), std::io::Error> {
std::os::unix::fs::symlink(target, link)
}
fn exists(&self, path: &Path) -> bool {
path.exists()
}
fn is_dir(&self, path: &Path) -> bool {
path.is_dir()
}
}

View 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"),
]
);
}
}

View File

@@ -0,0 +1,7 @@
pub mod fs;
pub mod memory;
pub mod reporter;
pub use fs::FileStoreImpl;
pub use memory::InMemoryFileStore;
pub use reporter::{JsonReporter, TerminalReporter};

View 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());
}
}

View File

@@ -16,10 +16,39 @@ pub struct Cli {
#[arg(long, short, global = true)]
pub verbose: bool,
/// Output in JSON format
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
pub command: Command,
}
// TODO(install): `notfiles` is not yet installed on PATH. Add `just install` recipe and
// `mise task` so `nf` / `notfiles` is available without `cargo run`. See Justfile.
// TODO(bootstrap): Wire `notstrap` into the CLI as `notfiles bootstrap`. Should clone the
// repo, resolve age key (Bitwarden/YubiKey/prompt), decrypt SOPS secrets, link packages,
// and run post-hooks. The notstrap crate already models this — needs a CLI entry point.
// TODO(migrate): Add `notfiles migrate <dir>` that takes a stow/chezmoi repo (from `detect`)
// and generates a notfiles.toml for it, then bulk-adopts all packages. Should call
// `detect::detect()`, let the user pick a source, and run `adopt` for each package.
// TODO(which): Add `notfiles which <path>` — reverse lookup from a target path (e.g.
// ~/.gitconfig) to its source package and file. Walk state entries and check read_link.
// TODO(doctor): Add `notfiles doctor` — holistic drift detection replacing drift-check.sh.
// Checks: broken symlinks, unmanaged dotfiles in $HOME, dirty git state, conflicts,
// packages in dir not listed in notfiles.toml. Should print a summary like `just doctor`.
// TODO(discover): Add `[defaults] discover = true` mode to notfiles.toml so all top-level
// dirs are treated as packages automatically, without maintaining an explicit `include`
// list. Opt-in via config, not the default (explicit is safer).
// TODO(edit): Add `notfiles edit <managed-path>` — resolve the source file for a managed
// symlink and open it in $EDITOR. Quality-of-life shortcut for day-to-day editing.
#[derive(Subcommand, Debug)]
pub enum Command {
/// Create a starter notfiles.toml
@@ -50,4 +79,31 @@ pub enum Command {
/// Specific packages to check (default: all)
packages: Vec<String>,
},
/// Validate config without making changes (CI-friendly)
Check,
/// Generate shell completions
Completions {
/// Shell to generate for
#[arg(value_enum)]
shell: clap_complete::Shell,
},
/// Show differences between source and target for copy-method packages
Diff {
/// Specific packages to diff (default: all copy-method packages)
packages: Vec<String>,
},
/// Auto-detect existing dotfile managers (stow, chezmoi, etc.)
Detect,
/// Move existing files into a package and replace with symlinks
Adopt {
/// Package to adopt files into
package: String,
/// File paths (relative to target dir) to adopt
files: Vec<String>,
},
}

View File

@@ -0,0 +1,300 @@
//! Auto-detection of existing dotfile managers.
//!
//! Scans common dotfile repo locations and identifies what's managing them.
use std::path::{Path, PathBuf};
use serde_json::json;
/// A dotfile manager detected on disk.
#[derive(Debug)]
pub struct DetectedManager {
pub kind: ManagerKind,
pub root: PathBuf,
pub packages: Vec<DetectedPackage>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ManagerKind {
/// GNU Stow — packages are top-level dirs, files mirror $HOME layout.
Stow,
/// chezmoi — uses `.chezmoi.*` config and `dot_` prefix convention.
Chezmoi,
/// Notfiles — has a `notfiles.toml` config file.
Notfiles,
/// Unknown structure — looks like a dotfile repo but manager unclear.
Unknown,
}
impl std::fmt::Display for ManagerKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ManagerKind::Stow => write!(f, "stow"),
ManagerKind::Chezmoi => write!(f, "chezmoi"),
ManagerKind::Notfiles => write!(f, "notfiles"),
ManagerKind::Unknown => write!(f, "unknown"),
}
}
}
/// A package (top-level directory) inside a detected dotfile repo.
#[derive(Debug)]
pub struct DetectedPackage {
pub name: String,
/// Files relative to the package directory (mirrors home layout).
pub files: Vec<PathBuf>,
}
/// Locations to probe relative to `$HOME`.
const CANDIDATE_DIRS: &[&str] = &[
"dotfiles",
".dotfiles",
"dot",
".dot",
"dots",
".dots",
"config/dotfiles",
".config/dotfiles",
];
/// Detect all dotfile managers reachable from `home`.
pub fn detect(home: &Path) -> Vec<DetectedManager> {
let mut found = Vec::new();
for rel in CANDIDATE_DIRS {
let dir = home.join(rel);
if dir.is_dir()
&& let Some(m) = probe(&dir)
{
found.push(m);
}
}
// chezmoi source dir at its default non-standard location
let chezmoi_src = home.join(".local/share/chezmoi");
if chezmoi_src.is_dir() && !found.iter().any(|m| m.root == chezmoi_src) {
found.push(DetectedManager {
kind: ManagerKind::Chezmoi,
packages: enumerate_chezmoi_packages(&chezmoi_src),
root: chezmoi_src,
});
}
found
}
fn probe(dir: &Path) -> Option<DetectedManager> {
let kind = classify(dir);
let packages = match kind {
ManagerKind::Stow | ManagerKind::Unknown => enumerate_stow_packages(dir),
ManagerKind::Chezmoi => enumerate_chezmoi_packages(dir),
ManagerKind::Notfiles => enumerate_notfiles_packages(dir),
};
Some(DetectedManager {
kind,
root: dir.to_path_buf(),
packages,
})
}
fn classify(dir: &Path) -> ManagerKind {
if dir.join("notfiles.toml").exists() {
return ManagerKind::Notfiles;
}
if dir.join(".chezmoi.toml.tmpl").exists()
|| dir.join(".chezmoi.yaml.tmpl").exists()
|| dir.join(".chezmoi.json.tmpl").exists()
|| dir.join(".chezmoi.toml").exists()
|| dir.join(".chezmoi.yaml").exists()
{
return ManagerKind::Chezmoi;
}
if dir.join(".stow-local-ignore").exists()
|| dir.join(".stowrc").exists()
|| has_stow_structure(dir)
{
return ManagerKind::Stow;
}
ManagerKind::Unknown
}
/// Heuristic: looks like stow if ≥2 top-level subdirs contain dot-dirs or
/// known config paths (`.config/`, `.local/`, `Library/`, etc.).
fn has_stow_structure(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
let mut stow_like = 0usize;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name();
let s = name.to_string_lossy();
if s.starts_with('.') || matches!(s.as_ref(), "target" | "src") {
continue;
}
if package_looks_like_stow(&path) {
stow_like += 1;
if stow_like >= 2 {
return true;
}
}
}
false
}
fn package_looks_like_stow(pkg_dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(pkg_dir) else {
return false;
};
for entry in entries.flatten() {
let name = entry.file_name();
let s = name.to_string_lossy();
if s.starts_with('.') || s == "Library" {
return true;
}
}
false
}
fn enumerate_stow_packages(root: &Path) -> Vec<DetectedPackage> {
let Ok(entries) = std::fs::read_dir(root) else {
return vec![];
};
let mut pkgs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') || matches!(name.as_str(), "target" | "src" | "docs" | "tests") {
continue;
}
let files = walk_files(&path, &path);
pkgs.push(DetectedPackage { name, files });
}
pkgs.sort_by(|a, b| a.name.cmp(&b.name));
pkgs
}
fn enumerate_chezmoi_packages(root: &Path) -> Vec<DetectedPackage> {
let files = walk_files(root, root);
if files.is_empty() {
vec![]
} else {
vec![DetectedPackage {
name: "(source)".into(),
files,
}]
}
}
fn enumerate_notfiles_packages(root: &Path) -> Vec<DetectedPackage> {
let toml_path = root.join("notfiles.toml");
if let Ok(content) = std::fs::read_to_string(&toml_path)
&& let Some(names) = parse_include_list(&content)
{
return names
.into_iter()
.map(|name| {
let pkg_dir = root.join(&name);
let files = walk_files(&pkg_dir, &pkg_dir);
DetectedPackage { name, files }
})
.collect();
}
enumerate_stow_packages(root)
}
/// Minimal parse of `include = ["a", "b"]` from TOML.
fn parse_include_list(toml: &str) -> Option<Vec<String>> {
let line = toml
.lines()
.find(|l| l.trim_start().starts_with("include"))?;
let bracket_start = line.find('[')?;
let bracket_end = line.find(']')?;
let inner = &line[bracket_start + 1..bracket_end];
let names: Vec<String> = inner
.split(',')
.map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
.filter(|s| !s.is_empty())
.collect();
if names.is_empty() { None } else { Some(names) }
}
/// Recursively collect file paths relative to `base`.
fn walk_files(dir: &Path, base: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return files;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if matches!(name.as_str(), ".git" | ".DS_Store") {
continue;
}
if path.is_dir() {
files.extend(walk_files(&path, base));
} else if path.is_file()
&& let Ok(rel) = path.strip_prefix(base)
{
files.push(rel.to_path_buf());
}
}
files
}
// ── Output ────────────────────────────────────────────────────────────────────
pub fn print_detected(managers: &[DetectedManager]) {
if managers.is_empty() {
println!("No dotfile managers detected.");
return;
}
println!("Detected dotfile managers:\n");
for m in managers {
let pkg_count = m.packages.len();
let file_count: usize = m.packages.iter().map(|p| p.files.len()).sum();
let pkg_names: Vec<&str> = m.packages.iter().map(|p| p.name.as_str()).collect();
println!(" \x1b[1m{}\x1b[0m {}", m.kind, m.root.display());
println!(
" {} package{}, {} file{}",
pkg_count,
if pkg_count == 1 { "" } else { "s" },
file_count,
if file_count == 1 { "" } else { "s" },
);
if !pkg_names.is_empty() {
println!(" packages: {}", pkg_names.join(", "));
}
println!();
}
}
pub fn print_detected_json(managers: &[DetectedManager]) {
let items: Vec<_> = managers
.iter()
.map(|m| {
json!({
"manager": m.kind.to_string(),
"root": m.root.to_string_lossy(),
"packages": m.packages.iter().map(|p| {
json!({
"name": p.name,
"files": p.files.iter()
.map(|f| f.to_string_lossy().to_string())
.collect::<Vec<_>>(),
})
}).collect::<Vec<_>>(),
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&items).unwrap_or_default()
);
}

View File

@@ -14,7 +14,9 @@ impl IgnoreMatcher {
// Match the pattern as a filename component and also as a path suffix.
let glob = Glob::new(pattern)
.or_else(|_| Glob::new(&format!("**/{pattern}")))
.map_err(|e| NotfilesError::Other(format!("invalid ignore pattern '{pattern}': {e}")))?;
.map_err(|e| {
NotfilesError::Glob(format!("invalid ignore pattern '{pattern}': {e}"))
})?;
builder.add(glob);
// Also add a recursive variant so "foo" matches "a/foo" etc.
#[allow(clippy::collapsible_if)]
@@ -26,7 +28,7 @@ impl IgnoreMatcher {
}
let globset = builder
.build()
.map_err(|e| NotfilesError::Other(format!("building ignore set: {e}")))?;
.map_err(|e| NotfilesError::Glob(format!("building ignore set: {e}")))?;
Ok(Self { globset })
}

View File

@@ -1,32 +1,108 @@
//! A modern dotfiles manager — pure Rust alternative to GNU Stow.
//!
//! Symlinks (or copies) files from organized "package" directories
//! into a target location (typically `~`). Supports include/exclude
//! filtering, platform gates, and multiple output formats.
//!
//! # Quick start
//!
//! ```no_run
//! use notfiles::{link, LinkOptions};
//! use std::path::Path;
//!
//! let opts = LinkOptions {
//! force: false,
//! no_backup: false,
//! dry_run: false,
//! verbose: true,
//! };
//! let state = link(Path::new("/home/user/dotfiles"), &[], &opts)
//! .expect("link failed");
//! ```
pub mod adapters;
pub mod cli;
pub mod detect;
pub mod ignore;
pub mod linker;
pub mod package;
pub mod ports;
pub mod status;
use anyhow::Result;
use std::path::Path;
pub use linker::{LinkOptions, State};
pub use package::resolve_packages;
use notcore::NotfilesError;
use notcore::reporter::Reporter;
pub use adapters::FileStoreImpl;
pub use linker::{LinkOptions, LinkResult, State};
pub use package::{
discover_packages_filtered, resolve_packages, resolve_packages_filtered_with_store,
resolve_packages_with_store,
};
pub use ports::FileStore;
/// Link all (or specified) packages using the real filesystem and
/// terminal output.
pub fn link(
dotfiles_dir: &Path,
packages: &[String],
opts: &LinkOptions,
) -> Result<State> {
) -> Result<State, NotfilesError> {
let reporter = adapters::TerminalReporter;
link_with_store(
dotfiles_dir,
packages,
opts,
&adapters::FileStoreImpl,
&reporter,
)
}
/// Link packages with injectable filesystem and reporter (for testing).
pub fn link_with_store(
dotfiles_dir: &Path,
packages: &[String],
opts: &LinkOptions,
fs: &dyn FileStore,
reporter: &dyn Reporter,
) -> Result<State, NotfilesError> {
let config = notcore::Config::load(dotfiles_dir)?;
let mut state = State::load(dotfiles_dir)?;
let pkgs = resolve_packages(dotfiles_dir, packages)?;
config.validate()?;
let mut state = linker::State::load(dotfiles_dir, fs)?;
let pkgs = package::resolve_packages_filtered_with_store(dotfiles_dir, packages, &config, fs)?;
for pkg in &pkgs {
linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts)?;
linker::link_package(dotfiles_dir, &config, &mut state, pkg, opts, fs, reporter)?;
}
state.save(dotfiles_dir)?;
state.save(dotfiles_dir, fs)?;
Ok(state)
}
pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<()> {
let mut state = State::load(dotfiles_dir)?;
/// Remove managed symlinks/copies for all (or specified) packages.
pub fn unlink(
dotfiles_dir: &Path,
packages: &[String],
opts: &LinkOptions,
) -> Result<(), NotfilesError> {
let reporter = adapters::TerminalReporter;
unlink_with_store(
dotfiles_dir,
packages,
opts,
&adapters::FileStoreImpl,
&reporter,
)
}
/// Unlink packages with injectable filesystem and reporter.
pub fn unlink_with_store(
dotfiles_dir: &Path,
packages: &[String],
opts: &LinkOptions,
fs: &dyn FileStore,
reporter: &dyn Reporter,
) -> Result<(), NotfilesError> {
let mut state = linker::State::load(dotfiles_dir, fs)?;
let pkgs = if packages.is_empty() {
state
.entries
@@ -39,8 +115,8 @@ pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> R
packages.to_vec()
};
for pkg in &pkgs {
linker::unlink_package(dotfiles_dir, &mut state, pkg, opts)?;
linker::unlink_package(dotfiles_dir, &mut state, pkg, opts, fs, reporter)?;
}
state.save(dotfiles_dir)?;
state.save(dotfiles_dir, fs)?;
Ok(())
}

View File

@@ -1,11 +1,12 @@
use std::fs;
use std::path::{Path, PathBuf};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::package::collect_files_with_store;
use crate::ports::FileStore;
use notcore::reporter::{LinkEvent, Reporter};
use notcore::{Config, Method, NotfilesError, expand_tilde};
use crate::package::collect_files;
const STATE_FILE: &str = ".notfiles-state.toml";
@@ -25,37 +26,51 @@ pub struct State {
}
impl State {
pub fn load(dotfiles_dir: &Path) -> Result<Self, NotfilesError> {
pub fn load(dotfiles_dir: &Path, fs: &dyn FileStore) -> Result<Self, NotfilesError> {
let path = dotfiles_dir.join(STATE_FILE);
if !path.exists() {
if !fs.exists(&path) {
return Ok(State::default());
}
let content = fs::read_to_string(&path)
let content = fs
.read_to_string(&path)
.map_err(|e| NotfilesError::State(format!("reading state: {e}")))?;
let state: State = toml::from_str(&content)
.map_err(|e| NotfilesError::State(format!("parsing state: {e}")))?;
Ok(state)
}
pub fn save(&self, dotfiles_dir: &Path) -> Result<(), NotfilesError> {
pub fn save(&self, dotfiles_dir: &Path, fs: &dyn FileStore) -> Result<(), NotfilesError> {
let path = dotfiles_dir.join(STATE_FILE);
let tmp_path = dotfiles_dir.join(format!("{STATE_FILE}.tmp"));
let content = toml::to_string_pretty(self)
.map_err(|e| NotfilesError::State(format!("serializing state: {e}")))?;
fs::write(&path, content)?;
fs.write(&tmp_path, content.as_bytes())
.map_err(|e| NotfilesError::State(format!("writing temp state: {e}")))?;
fs.rename(&tmp_path, &path)
.map_err(|e| NotfilesError::State(format!("renaming temp state: {e}")))?;
Ok(())
}
pub fn entries_for_package(&self, package: &str) -> Vec<&StateEntry> {
self.entries.iter().filter(|e| e.package == package).collect()
self.entries
.iter()
.filter(|e| e.package == package)
.collect()
}
pub fn remove_package(&mut self, package: &str) {
self.entries.retain(|e| e.package != package);
}
pub fn remove_entry(&mut self, package: &str, source: &str, target: &str) {
self.entries
.retain(|e| !(e.package == package && e.source == source && e.target == target));
}
pub fn add_entry(&mut self, entry: StateEntry) {
// Remove existing entry for same source+target, then add new
self.entries.retain(|e| !(e.source == entry.source && e.target == entry.target));
self.entries
.retain(|e| !(e.source == entry.source && e.target == entry.target));
self.entries.push(entry);
}
}
@@ -67,23 +82,39 @@ pub struct LinkOptions {
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(
dotfiles_dir: &Path,
config: &Config,
state: &mut State,
package: &str,
opts: &LinkOptions,
) -> Result<(), NotfilesError> {
fs: &dyn FileStore,
reporter: &dyn Reporter,
) -> Result<LinkResult, NotfilesError> {
let package_dir = dotfiles_dir.join(package);
let method = config.method_for(package);
let target_base = expand_tilde(config.target_for(package))?;
let files = collect_files(&package_dir, config, package)?;
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 opts.verbose {
println!(" {package}: no files to link");
}
return Ok(());
reporter.report(&LinkEvent::Skip {
source: package,
reason: "no files to link",
});
return Ok(result);
}
for relative in &files {
@@ -91,52 +122,75 @@ pub fn link_package(
let target = target_base.join(relative);
let source_display = format!("{package}/{}", relative.display());
// Check if already correctly linked
if is_already_linked(&source, &target, method) {
if opts.verbose {
println!(" \x1b[90mskip\x1b[0m {source_display} (already linked)");
}
// Check if already correctly linked / copied
if is_already_linked(&source, &target, method, fs) {
reporter.report(&LinkEvent::Skip {
source: &source_display,
reason: "already linked",
});
result.skipped += 1;
continue;
}
// Helper: save partial state then return an error.
let save_and_return =
|state: &mut State, e: NotfilesError| -> Result<LinkResult, NotfilesError> {
if !opts.dry_run {
let _ = state.save(dotfiles_dir, fs);
}
Err(e)
};
// Conflict detection
if target.exists() || target.symlink_metadata().is_ok() {
if fs.exists(&target) || fs.symlink_metadata(&target).is_ok() {
if !opts.force {
return Err(NotfilesError::Conflict {
return save_and_return(
state,
NotfilesError::Conflict {
path: target.clone(),
reason: format!(
"already exists (use --force to overwrite); source: {source_display}"
),
});
},
);
}
// Force mode: backup then remove
if !opts.no_backup {
let backup = backup_path(&target);
if opts.dry_run {
println!(" \x1b[33mwould backup\x1b[0m {} -> {}", target.display(), backup.display());
reporter.report(&LinkEvent::DryRun {
action: "backup",
source: &source_display,
target: &backup,
});
} else {
if opts.verbose {
println!(" \x1b[33mbackup\x1b[0m {} -> {}", target.display(), backup.display());
reporter.report(&LinkEvent::Backup {
from: &target,
to: &backup,
});
if let Err(e) = fs.rename(&target, &backup) {
return save_and_return(state, e.into());
}
fs::rename(&target, &backup)?;
result.backed_up += 1;
}
} else if !opts.dry_run {
if target.is_dir() {
fs::remove_dir_all(&target)?;
let rm_result = if fs.is_dir(&target) {
fs.remove_dir_all(&target)
} else {
fs::remove_file(&target)?;
fs.remove_file(&target)
};
if let Err(e) = rm_result {
return save_and_return(state, e.into());
}
}
}
// Create parent directories
if let Some(parent) = target.parent().filter(|p| !p.exists()) {
if let Some(parent) = target.parent().filter(|p| !fs.exists(p)) {
if opts.dry_run {
if opts.verbose {
println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display());
}
} else {
fs::create_dir_all(parent)?;
reporter.report(&LinkEvent::CreateDir { path: parent });
} else if let Err(e) = fs.create_dir_all(parent) {
return save_and_return(state, e.into());
}
}
@@ -147,21 +201,49 @@ pub fn link_package(
};
if opts.dry_run {
println!(" \x1b[36mwould {action_word}\x1b[0m {source_display} -> {}", target.display());
reporter.report(&LinkEvent::DryRun {
action: action_word,
source: &source_display,
target: &target,
});
} else {
match method {
let io_result = match method {
Method::Symlink => {
#[cfg(unix)]
std::os::unix::fs::symlink(&source, &target)?;
{
fs.symlink(&source, &target).map(|_| 0u64)
}
#[cfg(not(unix))]
fs::copy(&source, &target)?;
{
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"symlink not supported on this platform",
))
}
}
Method::Copy => {
fs::copy(&source, &target)?;
let content = fs.read(&source)?;
fs.write(&target, &content).map(|_| content.len() as u64)
}
};
if let Err(e) = io_result {
return save_and_return(state, e.into());
}
match method {
Method::Symlink => {
reporter.report(&LinkEvent::Link {
source: &source_display,
target: &target,
});
result.linked += 1;
}
Method::Copy => {
reporter.report(&LinkEvent::Copy {
source: &source_display,
target: &target,
});
result.copied += 1;
}
if opts.verbose {
println!(" \x1b[32m{action_word}\x1b[0m {source_display} -> {}", target.display());
}
state.add_entry(StateEntry {
@@ -174,30 +256,57 @@ pub fn link_package(
}
}
Ok(())
if !opts.dry_run {
state.save(dotfiles_dir, fs)?;
}
Ok(result)
}
pub fn unlink_package(
_dotfiles_dir: &Path,
dotfiles_dir: &Path,
state: &mut State,
package: &str,
opts: &LinkOptions,
fs: &dyn FileStore,
reporter: &dyn Reporter,
) -> Result<(), NotfilesError> {
let entries: Vec<StateEntry> = state.entries_for_package(package).into_iter().cloned().collect();
// 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.
let package_dir = dotfiles_dir.join(package);
let has_state_entries = !state.entries_for_package(package).is_empty();
if !fs.is_dir(&package_dir) && !has_state_entries {
return Err(NotfilesError::PackageNotFound {
name: package.to_string(),
});
}
let entries: Vec<StateEntry> = state
.entries_for_package(package)
.into_iter()
.cloned()
.collect();
if entries.is_empty() {
if opts.verbose {
println!(" {package}: nothing to unlink");
}
reporter.report(&LinkEvent::Skip {
source: package,
reason: "nothing to unlink",
});
return Ok(());
}
let mut removed_entries = Vec::new();
for entry in &entries {
let target = PathBuf::from(&entry.target);
let source = PathBuf::from(&entry.source);
if !target.exists() && target.symlink_metadata().is_err() {
if opts.verbose {
println!(" \x1b[90mskip\x1b[0m {} (already gone)", target.display());
if !fs.exists(&target) && fs.symlink_metadata(&target).is_err() {
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "already gone",
});
if !opts.dry_run {
removed_entries.push((entry.source.clone(), entry.target.clone()));
}
continue;
}
@@ -205,63 +314,185 @@ pub fn unlink_package(
match entry.method {
Method::Symlink => {
// Verify it's a symlink pointing to our source
if let Ok(link_target) = fs::read_link(&target) {
let source = PathBuf::from(&entry.source);
if let Ok(link_target) = fs.read_link(&target) {
if link_target != source {
if opts.verbose {
println!(
" \x1b[33mskip\x1b[0m {} (symlink points elsewhere)",
target.display()
);
}
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "symlink points elsewhere",
});
continue;
}
} else {
if opts.verbose {
println!(" \x1b[33mskip\x1b[0m {} (not a symlink)", target.display());
}
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "not a symlink",
});
continue;
}
}
Method::Copy => {
// For copies, trust the state file
Method::Copy => match (fs.read(&source), fs.read(&target)) {
(Ok(source_bytes), Ok(target_bytes)) if source_bytes == target_bytes => {}
(Ok(_), Ok(_)) => {
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "copied file diverged from source",
});
continue;
}
(Err(_), _) => {
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "source missing for copied file",
});
continue;
}
(_, Err(_)) => {
reporter.report(&LinkEvent::Skip {
source: &target.to_string_lossy(),
reason: "cannot read copied target",
});
continue;
}
},
}
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 {
if target.is_dir() {
fs::remove_dir_all(&target)?;
if fs.is_dir(&target) {
fs.remove_dir_all(&target)?;
} else {
fs::remove_file(&target)?;
}
if opts.verbose {
println!(" \x1b[31mremove\x1b[0m {}", target.display());
fs.remove_file(&target)?;
}
reporter.report(&LinkEvent::Remove { target: &target });
// Clean up empty parent dirs
cleanup_empty_parents(&target);
cleanup_empty_parents(&target, fs);
removed_entries.push((entry.source.clone(), entry.target.clone()));
}
}
if !opts.dry_run {
state.remove_package(package);
for (source, target) in removed_entries {
state.remove_entry(package, &source, &target);
}
state.save(dotfiles_dir, fs)?;
}
Ok(())
}
fn is_already_linked(source: &Path, target: &Path, method: Method) -> bool {
#[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 {
match method {
Method::Symlink => {
if let Ok(link_target) = fs::read_link(target) {
if let Ok(link_target) = fs.read_link(target) {
link_target == source
} else {
false
}
}
Method::Copy => false, // Always re-copy
Method::Copy => match (fs.read(source), fs.read(target)) {
(Ok(source_bytes), Ok(target_bytes)) => source_bytes == target_bytes,
_ => false,
},
}
}
@@ -271,15 +502,19 @@ fn backup_path(path: &Path) -> PathBuf {
PathBuf::from(format!("{name}.notfiles-backup-{timestamp}"))
}
fn cleanup_empty_parents(path: &Path) {
fn cleanup_empty_parents(path: &Path, fs: &dyn FileStore) {
let mut dir = path.parent();
while let Some(parent) = dir {
// Stop at home dir or root
if Some(parent.to_path_buf()) == dirs::home_dir() || parent == Path::new("/") {
break;
}
if fs::read_dir(parent).map(|mut d| d.next().is_none()).unwrap_or(false) {
let _ = fs::remove_dir(parent);
if fs
.read_dir(parent)
.map(|children| children.is_empty())
.unwrap_or(false)
{
let _ = fs.remove_dir(parent);
dir = parent.parent();
} else {
break;

View File

@@ -1,12 +1,15 @@
use anyhow::{Context, Result};
use clap::Parser;
use clap::{CommandFactory, Parser};
use std::fs;
use notfiles::cli::{Cli, Command};
use notfiles::linker::{LinkOptions, State};
use notfiles::package::resolve_packages;
use notfiles::{linker, status};
use notcore::Config;
use notcore::reporter::Reporter;
use notfiles::adapters::{JsonReporter, TerminalReporter};
use notfiles::cli::{Cli, Command};
use notfiles::detect;
use notfiles::linker::{LinkOptions, State};
use notfiles::package::{resolve_packages_filtered_with_store, resolve_packages_with_store};
use notfiles::{adapters, linker, status};
fn main() -> Result<()> {
let cli = Cli::parse();
@@ -16,16 +19,35 @@ fn main() -> Result<()> {
let dotfiles_dir = fs::canonicalize(&dotfiles_dir)
.with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?;
let reporter: Box<dyn Reporter> = if cli.json {
Box::new(JsonReporter)
} else {
Box::new(TerminalReporter)
};
match cli.command {
Command::Detect => {
let home = std::env::var("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| dotfiles_dir.clone());
let managers = detect::detect(&home);
if cli.json {
detect::print_detected_json(&managers);
} else {
detect::print_detected(&managers);
}
}
Command::Init => cmd_init(&dotfiles_dir)?,
Command::Link {
force,
no_backup,
packages,
} => {
let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?;
let mut state = State::load(&dotfiles_dir)?;
let pkgs = resolve_packages(&dotfiles_dir, &packages)?;
config.validate()?;
let mut state = State::load(&dotfiles_dir, fs)?;
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
let opts = LinkOptions {
force,
no_backup,
@@ -37,16 +59,26 @@ fn main() -> Result<()> {
println!("\x1b[36m(dry run)\x1b[0m");
}
let mut results = Vec::new();
for pkg in &pkgs {
if cli.verbose || cli.dry_run {
println!("Linking {pkg}...");
}
linker::link_package(&dotfiles_dir, &config, &mut state, pkg, &opts)?;
let result = linker::link_package(
&dotfiles_dir,
&config,
&mut state,
pkg,
&opts,
fs,
&*reporter,
)?;
results.push(result);
}
if !cli.dry_run {
state.save(&dotfiles_dir)?;
let count: usize = pkgs.iter().map(|p| state.entries_for_package(p).len()).sum();
state.save(&dotfiles_dir, fs)?;
let count: usize = results.iter().map(|r| r.linked + r.copied).sum();
println!(
"\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m",
if count == 1 { "" } else { "s" },
@@ -56,9 +88,10 @@ fn main() -> Result<()> {
}
}
Command::Unlink { packages } => {
let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?;
let _ = &config; // loaded but not needed for unlink
let mut state = State::load(&dotfiles_dir)?;
let mut state = State::load(&dotfiles_dir, fs)?;
let pkgs = if packages.is_empty() {
state
.entries
@@ -69,7 +102,7 @@ fn main() -> Result<()> {
.collect::<Vec<_>>()
} else {
// Validate requested packages exist in state
let _ = resolve_packages(&dotfiles_dir, &packages).or_else(|_| {
let _ = resolve_packages_with_store(&dotfiles_dir, &packages, fs).or_else(|_| {
// Package dir might be gone but state entries exist — that's fine for unlink
Ok::<Vec<String>, anyhow::Error>(packages.clone())
});
@@ -90,11 +123,11 @@ fn main() -> Result<()> {
if cli.verbose || cli.dry_run {
println!("Unlinking {pkg}...");
}
linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts)?;
linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts, fs, &*reporter)?;
}
if !cli.dry_run {
state.save(&dotfiles_dir)?;
state.save(&dotfiles_dir, fs)?;
println!(
"\x1b[32mUnlinked {} package{}.\x1b[0m",
pkgs.len(),
@@ -102,17 +135,103 @@ fn main() -> Result<()> {
);
}
}
Command::Status { packages } => {
Command::Completions { shell } => {
clap_complete::generate(
shell,
&mut Cli::command(),
"notfiles",
&mut std::io::stdout(),
);
}
Command::Check => {
let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?;
let state = State::load(&dotfiles_dir)?;
let pkgs = resolve_packages(&dotfiles_dir, &packages)?;
config.validate()?;
let all = resolve_packages_filtered_with_store(&dotfiles_dir, &[], &config, fs)?;
if cli.json {
let skipped: Vec<String> = {
let all_unfiltered =
notfiles::package::resolve_packages_with_store(&dotfiles_dir, &[], fs)?;
all_unfiltered
.into_iter()
.filter(|p| !all.contains(p))
.collect()
};
let obj = serde_json::json!({
"valid": true,
"packages": all,
"skipped": skipped,
});
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
} else {
println!("notfiles.toml: \x1b[32mvalid\x1b[0m");
println!("Packages: {} ({} total)", all.join(", "), all.len());
}
}
Command::Diff { packages } => {
let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?;
config.validate()?;
let state = State::load(&dotfiles_dir, fs)?;
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
for pkg in &pkgs {
let entries = status::package_status(&dotfiles_dir, &config, &state, pkg);
let entries = status::diff_package(&dotfiles_dir, &config, &state, pkg, fs);
status::print_diff(pkg, &entries);
}
}
Command::Adopt { package, files } => {
let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?;
let mut state = State::load(&dotfiles_dir, fs)?;
let opts = LinkOptions {
force: false,
no_backup: false,
dry_run: cli.dry_run,
verbose: cli.verbose,
};
if cli.dry_run {
println!("\x1b[36m(dry run)\x1b[0m");
}
let result = linker::adopt_files(
&dotfiles_dir,
&config,
&mut state,
&package,
&files,
&opts,
fs,
&*reporter,
)?;
if !cli.dry_run {
println!(
"\x1b[32mAdopted {} file{} into {package}.\x1b[0m",
result.linked,
if result.linked == 1 { "" } else { "s" },
);
}
}
Command::Status { packages } => {
let fs = &adapters::FileStoreImpl;
let config = Config::load(&dotfiles_dir)?;
config.validate()?;
let state = State::load(&dotfiles_dir, fs)?;
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
for pkg in &pkgs {
let entries = status::package_status(&dotfiles_dir, &config, &state, pkg, fs);
if cli.json {
status::print_status_json(pkg, &entries);
} else {
status::print_status(pkg, &entries);
}
}
}
}
Ok(())
}

View File

@@ -1,17 +1,61 @@
use std::fs;
use std::path::{Path, PathBuf};
use notcore::{Config, NotfilesError};
use crate::adapters::FileStoreImpl;
use crate::ignore::IgnoreMatcher;
use crate::ports::FileStore;
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).
pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> {
discover_packages_with_store(dotfiles_dir, &FileStoreImpl)
}
pub fn discover_packages_with_store(
dotfiles_dir: &Path,
fs: &dyn FileStore,
) -> Result<Vec<String>, NotfilesError> {
let mut packages = Vec::new();
for entry in fs::read_dir(dotfiles_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let name = entry.file_name().to_string_lossy().to_string();
// TODO(#19): Replace implicit top-level directory discovery with an explicit
// package contract so mixed-purpose repos do not accidentally link
// operational directories like scripts/ or docs/.
for path in fs.read_dir(dotfiles_dir)? {
if fs.is_dir(&path) {
let name = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default();
// Skip hidden dirs and common non-package dirs
if !name.starts_with('.') {
packages.push(name);
@@ -28,13 +72,36 @@ pub fn resolve_packages(
dotfiles_dir: &Path,
requested: &[String],
) -> Result<Vec<String>, NotfilesError> {
let available = discover_packages(dotfiles_dir)?;
resolve_packages_with_store(dotfiles_dir, requested, &FileStoreImpl)
}
pub fn resolve_packages_with_store(
dotfiles_dir: &Path,
requested: &[String],
fs: &dyn FileStore,
) -> Result<Vec<String>, NotfilesError> {
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() {
return Ok(available);
}
for name in requested {
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())
@@ -45,11 +112,20 @@ pub fn collect_files(
package_dir: &Path,
config: &Config,
package_name: &str,
) -> Result<Vec<PathBuf>, NotfilesError> {
collect_files_with_store(package_dir, config, package_name, &FileStoreImpl)
}
pub fn collect_files_with_store(
package_dir: &Path,
config: &Config,
package_name: &str,
fs: &dyn FileStore,
) -> Result<Vec<PathBuf>, NotfilesError> {
let patterns = config.ignore_patterns_for(package_name);
let matcher = IgnoreMatcher::new(&patterns)?;
let mut files = Vec::new();
walk_dir(package_dir, package_dir, &matcher, &mut files)?;
walk_dir(package_dir, package_dir, &matcher, &mut files, fs)?;
files.sort();
Ok(files)
}
@@ -59,18 +135,17 @@ fn walk_dir(
current: &Path,
matcher: &IgnoreMatcher,
files: &mut Vec<PathBuf>,
fs: &dyn FileStore,
) -> Result<(), NotfilesError> {
for entry in fs::read_dir(current)? {
let entry = entry?;
let path = entry.path();
for path in fs.read_dir(current)? {
let relative = path.strip_prefix(base).unwrap().to_path_buf();
if matcher.is_ignored(&relative) {
continue;
}
if path.is_dir() {
walk_dir(base, &path, matcher, files)?;
if fs.is_dir(&path) {
walk_dir(base, &path, matcher, files, fs)?;
} else {
files.push(relative);
}
@@ -81,6 +156,7 @@ fn walk_dir(
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
@@ -94,6 +170,40 @@ mod tests {
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]
fn test_resolve_specific_packages() {
let tmp = TempDir::new().unwrap();

View File

@@ -0,0 +1,57 @@
use std::path::{Path, PathBuf};
/// FileStore trait abstracts file system I/O operations.
///
/// This trait defines the interface for all file system interactions in the notfiles
/// linker, enabling testing with mock implementations and potential future backends.
pub trait FileStore {
/// Read entire file to bytes.
fn read(&self, path: &Path) -> Result<Vec<u8>, std::io::Error>;
/// Read entire file to string.
fn read_to_string(&self, path: &Path) -> Result<String, std::io::Error>;
/// Write content to file.
fn write(&self, path: &Path, contents: &[u8]) -> Result<(), std::io::Error>;
/// Rename a file or directory.
fn rename(&self, from: &Path, to: &Path) -> Result<(), std::io::Error>;
/// Remove a file.
fn remove_file(&self, path: &Path) -> Result<(), std::io::Error>;
/// Recursively remove a directory and all its contents.
fn remove_dir_all(&self, path: &Path) -> Result<(), std::io::Error>;
/// Remove an empty directory.
fn remove_dir(&self, path: &Path) -> Result<(), std::io::Error>;
/// Read the target of a symbolic link.
fn read_link(&self, path: &Path) -> Result<PathBuf, std::io::Error>;
/// Get metadata for a file or directory (without following symlinks).
fn symlink_metadata(&self, path: &Path) -> Result<std::fs::Metadata, std::io::Error>;
/// Get metadata for a file or directory (following symlinks).
fn metadata(&self, path: &Path) -> Result<std::fs::Metadata, std::io::Error>;
/// Create a directory and all missing parent directories.
fn create_dir_all(&self, path: &Path) -> Result<(), std::io::Error>;
/// List direct children of a directory.
fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, std::io::Error>;
/// Create a symbolic link at `link` pointing to `target`.
///
/// On Unix systems, this creates a symlink. On Windows with symlink support enabled,
/// this may also create a symlink. Fallback behavior (e.g., copying) is handled by
/// the caller.
#[cfg(unix)]
fn symlink(&self, target: &Path, link: &Path) -> Result<(), std::io::Error>;
/// Check if a path exists.
fn exists(&self, path: &Path) -> bool;
/// Check if a path is a directory.
fn is_dir(&self, path: &Path) -> bool;
}

View File

@@ -1,9 +1,11 @@
use std::fs;
use std::path::{Path, PathBuf};
use notcore::{Config, Method, expand_tilde};
use serde_json::json;
use crate::linker::State;
use crate::package::collect_files;
use crate::package::collect_files_with_store;
use crate::ports::FileStore;
use notcore::{Config, Method, expand_tilde};
#[derive(Debug, PartialEq)]
pub enum FileStatus {
@@ -37,6 +39,7 @@ pub fn package_status(
config: &Config,
state: &State,
package: &str,
fs: &dyn FileStore,
) -> Vec<StatusEntry> {
let mut results = Vec::new();
let package_dir = dotfiles_dir.join(package);
@@ -47,7 +50,7 @@ pub fn package_status(
};
// Check files that should exist
if let Ok(files) = collect_files(&package_dir, config, package) {
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);
@@ -55,26 +58,31 @@ pub fn package_status(
let status = match method {
Method::Symlink => {
if let Ok(link_target) = fs::read_link(&target) {
if let Ok(link_target) = fs.read_link(&target) {
if link_target == source {
FileStatus::Linked
} else {
FileStatus::Conflict
}
} else if target.exists() {
} else if fs.exists(&target) {
FileStatus::Conflict
} else {
FileStatus::Missing
}
}
Method::Copy => {
let has_state = state.entries.iter().any(|e| {
let tracked = state.entries.iter().any(|e| {
e.package == package
&& e.target == target.to_string_lossy().as_ref()
&& e.source == source.to_string_lossy().as_ref()
});
if has_state && target.exists() {
let content_matches = match (fs.read(&source), fs.read(&target)) {
(Ok(source_bytes), Ok(target_bytes)) => source_bytes == target_bytes,
_ => false,
};
if tracked && content_matches {
FileStatus::Copied
} else if target.exists() {
} else if fs.exists(&target) {
FileStatus::Conflict
} else {
FileStatus::Missing
@@ -93,7 +101,7 @@ pub fn package_status(
// Check for orphans: entries in state that no longer have a source file
for entry in state.entries_for_package(package) {
let source = PathBuf::from(&entry.source);
if !source.exists() {
if !fs.exists(&source) {
let target = PathBuf::from(&entry.target);
// Only add if we didn't already report this target
if !results.iter().any(|r| r.target == target) {
@@ -115,6 +123,126 @@ pub fn package_status(
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]) {
if entries.is_empty() {
println!(" {package}: (empty)");
@@ -122,6 +250,26 @@ pub fn print_status(package: &str, entries: &[StatusEntry]) {
}
println!(" \x1b[1m{package}\x1b[0m:");
for entry in entries {
println!(" {} {} -> {}", entry.status, entry.source_display, entry.target.display());
println!(
" {} {} -> {}",
entry.status,
entry.source_display,
entry.target.display()
);
}
}
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());
}

View 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

View File

@@ -0,0 +1,2 @@
[font]
size = 14.0

View File

@@ -0,0 +1,3 @@
# Bootstrap Runbook
Steps for setting up a new machine.

View File

@@ -0,0 +1,2 @@
# Example fish config
set -gx EDITOR nvim

View File

@@ -0,0 +1,5 @@
[user]
name = Example User
email = user@example.com
[init]
defaultBranch = main

View File

@@ -0,0 +1,2 @@
[tools]
node = "lts"

View File

@@ -0,0 +1,4 @@
{ config, pkgs, ... }:
{
environment.systemPackages = with pkgs; [ vim git ];
}

View 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"]

View File

@@ -0,0 +1,2 @@
# Example aliases
alias ll = ls -l

View File

@@ -0,0 +1,2 @@
# Example nushell config
$env.config.show_banner = false

View File

@@ -0,0 +1,2 @@
# Example nushell env
$env.EDITOR = "nvim"

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Health check script — NOT a stow package.
echo "All good"

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Shared helpers — NOT a stow package.
log() { echo "[$(date)] $*"; }

View File

@@ -0,0 +1,3 @@
{
"EXAMPLE_KEY": "replace-me"
}

View File

@@ -0,0 +1,2 @@
[character]
success_symbol = "[>](bold green)"

View File

@@ -0,0 +1,4 @@
{
"editor.fontSize": 14,
"editor.tabSize": 2
}

View File

@@ -0,0 +1,2 @@
# Example zshrc
export EDITOR=nvim

View File

@@ -94,7 +94,13 @@ fn test_link_and_status() {
let gitconfig = target.join(".gitconfig");
assert!(gitconfig.exists());
assert!(gitconfig.symlink_metadata().unwrap().file_type().is_symlink());
assert!(
gitconfig
.symlink_metadata()
.unwrap()
.file_type()
.is_symlink()
);
// State file should exist
assert!(dotfiles.join(".notfiles-state.toml").exists());
@@ -199,7 +205,14 @@ fn test_force_with_backup() {
assert!(ok);
// The link should now exist
assert!(target.join(".gitconfig").symlink_metadata().unwrap().file_type().is_symlink());
assert!(
target
.join(".gitconfig")
.symlink_metadata()
.unwrap()
.file_type()
.is_symlink()
);
// A backup should exist
let backups: Vec<_> = fs::read_dir(&target)
@@ -232,11 +245,7 @@ fn test_force_no_backup() {
let backups: Vec<_> = fs::read_dir(&target)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.contains("notfiles-backup")
})
.filter(|e| e.file_name().to_string_lossy().contains("notfiles-backup"))
.collect();
assert_eq!(backups.len(), 0);
}
@@ -272,8 +281,17 @@ method = "copy"
let ssh_config = target.join(".ssh/config");
assert!(ssh_config.exists());
// Should NOT be a symlink
assert!(!ssh_config.symlink_metadata().unwrap().file_type().is_symlink());
assert_eq!(fs::read_to_string(&ssh_config).unwrap(), "Host *\n AddKeysToAgent yes");
assert!(
!ssh_config
.symlink_metadata()
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(
fs::read_to_string(&ssh_config).unwrap(),
"Host *\n AddKeysToAgent yes"
);
// Status should show "copied"
let (stdout, _, _) = run(&dotfiles, &["status", "ssh"]);
@@ -285,6 +303,89 @@ method = "copy"
assert!(!ssh_config.exists());
}
#[test]
fn test_status_flags_diverged_copy_as_conflict() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
let ssh = dotfiles.join("ssh");
fs::create_dir_all(ssh.join(".ssh")).unwrap();
fs::write(ssh.join(".ssh/config"), "Host *\n AddKeysToAgent yes").unwrap();
let config = format!(
r#"[defaults]
target = "{}"
ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfiles-state.toml"]
[packages.ssh]
method = "copy"
"#,
target.display()
);
fs::write(dotfiles.join("notfiles.toml"), config).unwrap();
let (_, _, ok) = run(&dotfiles, &["link", "ssh"]);
assert!(ok);
let ssh_config = target.join(".ssh/config");
fs::write(&ssh_config, "Host github.com\n User joe\n").unwrap();
let (stdout, _, ok) = run(&dotfiles, &["status", "ssh"]);
assert!(ok);
assert!(
stdout.contains("conflict"),
"diverged copy should be reported as conflict, stdout={stdout}"
);
}
#[test]
fn test_unlink_preserves_diverged_copy_and_state() {
let tmp = TempDir::new().unwrap();
setup_dotfiles(&tmp);
let dotfiles = tmp.path().join("dotfiles");
let target = tmp.path().join("home");
let ssh = dotfiles.join("ssh");
fs::create_dir_all(ssh.join(".ssh")).unwrap();
fs::write(ssh.join(".ssh/config"), "Host *\n AddKeysToAgent yes").unwrap();
let config = format!(
r#"[defaults]
target = "{}"
ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfiles-state.toml"]
[packages.ssh]
method = "copy"
"#,
target.display()
);
fs::write(dotfiles.join("notfiles.toml"), config).unwrap();
let (_, _, ok) = run(&dotfiles, &["link", "ssh"]);
assert!(ok);
let ssh_config = target.join(".ssh/config");
fs::write(&ssh_config, "Host github.com\n User joe\n").unwrap();
let (stdout, stderr, ok) = run(&dotfiles, &["unlink", "ssh", "--verbose"]);
assert!(
ok,
"unlink should not fail: stdout={stdout} stderr={stderr}"
);
assert!(
ssh_config.exists(),
"diverged copy should not be removed during unlink"
);
let state = fs::read_to_string(dotfiles.join(".notfiles-state.toml")).unwrap();
assert!(
state.contains(".ssh/config"),
"state entry must be retained when unlink skips a diverged copy: {state}"
);
}
#[test]
fn test_dry_run() {
let tmp = TempDir::new().unwrap();
@@ -362,3 +463,164 @@ fn test_unlink_cleans_empty_dirs() {
// The .config/zsh directory should be cleaned up
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());
}

View File

@@ -0,0 +1,33 @@
[package]
name = "notforge"
version = "0.1.0"
edition = "2024"
license.workspace = true
repository.workspace = true
homepage.workspace = true
keywords.workspace = true
categories.workspace = true
description = "Forge lifecycle and repository provisioning for notfiles"
[lib]
name = "notforge"
path = "src/lib.rs"
[[bin]]
name = "notforge"
path = "src/main.rs"
[dependencies]
anyhow = { workspace = true }
base64 = { workspace = true }
clap = { workspace = true }
miette = { workspace = true }
notcore = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
ureq = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

View File

@@ -0,0 +1,176 @@
use serde::{Deserialize, Deserializer, Serialize};
use std::path::Path;
use crate::error::NotforgeError;
/// Top-level forge configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForgeConfig {
/// Gitea backend configuration.
pub gitea: GiteaConfig,
/// Repositories managed by this forge configuration.
#[serde(default)]
pub repositories: Vec<RepoSpec>,
}
/// Gitea backend configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GiteaConfig {
/// Base HTTP URL for the Gitea API.
pub base_url: String,
/// Default owner namespace for repositories.
pub owner: String,
/// Hostname used in generated SSH clone URLs.
pub ssh_host: String,
/// SSH port used in generated SSH clone URLs.
pub ssh_port: u16,
/// Backend mode used to verify or start Gitea.
pub mode: GiteaMode,
/// Authentication configuration for Gitea API calls.
#[serde(default)]
pub auth: ForgeAuthConfig,
}
/// Supported Gitea backend modes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum GiteaMode {
/// Run Gitea directly as a local process.
LocalProcess(LocalProcessConfig),
/// Run Gitea via a local container runtime.
LocalContainer(LocalContainerConfig),
/// Verify or start Gitea as a systemd service on a VM.
VmSystemd(VmSystemdConfig),
/// Use an already-running Gitea instance.
Existing,
}
impl<'de> Deserialize<'de> for GiteaMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum RawMode {
Name(String),
Tagged {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
config: Option<toml::Value>,
},
}
let raw = RawMode::deserialize(deserializer)?;
match raw {
RawMode::Name(name) if name == "existing" => Ok(Self::Existing),
RawMode::Name(name) => Err(serde::de::Error::custom(format!(
"unknown gitea mode '{name}'"
))),
RawMode::Tagged { kind, config } => match kind.as_str() {
"existing" => Ok(Self::Existing),
"local-process" => Ok(Self::LocalProcess(
config
.ok_or_else(|| serde::de::Error::custom("local-process requires config"))?
.try_into()
.map_err(serde::de::Error::custom)?,
)),
"local-container" => Ok(Self::LocalContainer(
config
.ok_or_else(|| serde::de::Error::custom("local-container requires config"))?
.try_into()
.map_err(serde::de::Error::custom)?,
)),
"vm-systemd" => Ok(Self::VmSystemd(
config
.ok_or_else(|| serde::de::Error::custom("vm-systemd requires config"))?
.try_into()
.map_err(serde::de::Error::custom)?,
)),
other => Err(serde::de::Error::custom(format!(
"unknown gitea mode '{other}'"
))),
},
}
}
}
/// Local process runner configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalProcessConfig {
/// Path or command name for the Gitea binary.
pub binary: String,
/// Working directory for the Gitea process.
pub work_dir: String,
/// Gitea app.ini path.
pub config_path: String,
}
/// Local container runner configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalContainerConfig {
/// Container runtime executable, such as docker or podman.
pub runtime: String,
/// Container name to verify or start.
pub container_name: String,
/// Container image to run when missing.
pub image: String,
/// Host data directory mounted into the container.
pub data_dir: String,
}
/// Remote VM systemd runner configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VmSystemdConfig {
/// SSH user for the VM.
pub ssh_user: String,
/// SSH host for the VM.
pub ssh_host: String,
/// systemd service name for Gitea.
pub service_name: String,
}
/// Authentication configuration for Gitea API calls.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ForgeAuthConfig {
/// Token secret reference, preferred when available.
pub token: Option<ForgeSecretRef>,
/// Username secret reference for basic auth fallback.
pub username: Option<ForgeSecretRef>,
/// Password secret reference for basic auth fallback.
pub password: Option<ForgeSecretRef>,
}
/// Secret reference used by notforge.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "source", rename_all = "lowercase")]
pub enum ForgeSecretRef {
/// Resolve from an environment variable.
Env { key: String },
/// Resolve from a 1Password URI.
Op { uri: String },
}
/// Repository specification managed by a forge config.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepoSpec {
/// Repository owner namespace.
pub owner: String,
/// Repository name.
pub name: String,
/// Whether the repository should be private.
pub private: bool,
/// Optional repository description.
pub description: Option<String>,
/// Local git remote name.
pub remote_name: String,
}
/// Load a forge configuration from TOML.
pub fn load_config(path: &Path) -> Result<ForgeConfig, NotforgeError> {
notcore::config::load_toml_file(
path,
|path, err| NotforgeError::Config(format!("reading {}: {err}", path.display())),
|path, err| NotforgeError::Config(format!("parsing {}: {err}", path.display())),
)
}

View File

@@ -0,0 +1,36 @@
use miette::Diagnostic;
use thiserror::Error;
/// Error type for forge lifecycle, API, git, and secret-resolution failures.
#[derive(Debug, Diagnostic, Error)]
pub enum NotforgeError {
/// Configuration could not be loaded or parsed.
#[error("configuration error: {0}")]
#[diagnostic(code(notforge::config))]
Config(String),
/// A required secret could not be resolved.
#[error("secret resolution error: {0}")]
#[diagnostic(code(notforge::secret))]
Secret(String),
/// Forge API communication failed.
#[error("HTTP error: {0}")]
#[diagnostic(code(notforge::http))]
Http(String),
/// A lifecycle command failed.
#[error("command error: {0}")]
#[diagnostic(code(notforge::command))]
Command(String),
/// A git operation failed.
#[error("git error: {0}")]
#[diagnostic(code(notforge::git))]
Git(String),
/// Repository lookup or provisioning failed.
#[error("repository error: {0}")]
#[diagnostic(code(notforge::repository))]
Repository(String),
}

View File

@@ -0,0 +1,302 @@
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::config::RepoSpec;
use crate::error::NotforgeError;
use crate::ports::{ForgeApi, ForgeAuth, ForgeVersion, RemoteRepository};
/// HTTP method used by the Gitea transport boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
/// HTTP GET.
Get,
/// HTTP POST.
Post,
}
impl std::fmt::Display for HttpMethod {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Get => formatter.write_str("GET"),
Self::Post => formatter.write_str("POST"),
}
}
}
/// Transport-level request sent by the Gitea adapter.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GiteaHttpRequest {
/// HTTP method.
pub method: HttpMethod,
/// Absolute URL to call.
pub url: String,
/// Optional Authorization header value.
pub authorization: Option<String>,
/// Optional JSON body.
pub body: Option<Value>,
}
/// Transport-level response returned to the Gitea adapter.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GiteaHttpResponse {
/// HTTP status code.
pub status: u16,
/// Parsed JSON response body, or `null` for an empty body.
pub body: Value,
}
/// HTTP transport boundary for Gitea API requests.
pub trait GiteaTransport {
/// Send a transport-level request.
fn send(&self, request: GiteaHttpRequest) -> Result<GiteaHttpResponse, NotforgeError>;
}
/// Gitea API adapter that implements [`ForgeApi`].
#[derive(Debug, Clone)]
pub struct GiteaHttpApi<T = UreqGiteaTransport> {
base_url: String,
transport: T,
}
impl GiteaHttpApi<UreqGiteaTransport> {
/// Create a Gitea API adapter backed by the default blocking HTTP transport.
pub fn new(base_url: impl Into<String>) -> Self {
Self::with_transport(base_url, UreqGiteaTransport::default())
}
}
impl<T> GiteaHttpApi<T>
where
T: GiteaTransport,
{
/// Create a Gitea API adapter with an injected transport.
pub fn with_transport(base_url: impl Into<String>, transport: T) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
transport,
}
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
fn get(&self, path: &str) -> Result<GiteaHttpResponse, NotforgeError> {
self.transport.send(GiteaHttpRequest {
method: HttpMethod::Get,
url: self.url(path),
authorization: None,
body: None,
})
}
fn post(
&self,
path: &str,
auth: &ForgeAuth,
body: Value,
) -> Result<GiteaHttpResponse, NotforgeError> {
self.transport.send(GiteaHttpRequest {
method: HttpMethod::Post,
url: self.url(path),
authorization: Some(authorization_header(auth)),
body: Some(body),
})
}
}
impl<T> ForgeApi for GiteaHttpApi<T>
where
T: GiteaTransport,
{
fn version(&self) -> Result<ForgeVersion, NotforgeError> {
let response = self.get("/api/v1/version")?;
require_status(response.status, &[200], "get Gitea version")?;
let payload: GiteaVersionResponse = parse_body(response.body, "Gitea version response")?;
Ok(ForgeVersion {
version: payload.version,
})
}
fn repo(&self, owner: &str, name: &str) -> Result<Option<RemoteRepository>, NotforgeError> {
let response = self.get(&format!("/api/v1/repos/{owner}/{name}"))?;
if response.status == 404 {
return Ok(None);
}
require_status(response.status, &[200], "get Gitea repository")?;
let payload: GiteaRepositoryResponse =
parse_body(response.body, "Gitea repository response")?;
Ok(Some(payload.into_remote_repository()))
}
fn create_repo(
&self,
spec: &RepoSpec,
auth: &ForgeAuth,
) -> Result<RemoteRepository, NotforgeError> {
let response = self.post("/api/v1/user/repos", auth, create_repo_body(spec))?;
require_status(response.status, &[200, 201], "create Gitea repository")?;
let payload: GiteaRepositoryResponse =
parse_body(response.body, "Gitea repository response")?;
Ok(payload.into_remote_repository())
}
}
/// Blocking HTTP transport implemented with `ureq`.
#[derive(Debug, Clone)]
pub struct UreqGiteaTransport {
agent: ureq::Agent,
}
impl Default for UreqGiteaTransport {
fn default() -> Self {
Self {
agent: ureq::Agent::new(),
}
}
}
impl UreqGiteaTransport {
/// Create a transport with a custom `ureq` agent.
pub fn new(agent: ureq::Agent) -> Self {
Self { agent }
}
}
impl GiteaTransport for UreqGiteaTransport {
fn send(&self, request: GiteaHttpRequest) -> Result<GiteaHttpResponse, NotforgeError> {
let method = request.method;
let url = request.url.clone();
let mut call = match method {
HttpMethod::Get => self.agent.get(&url),
HttpMethod::Post => self.agent.post(&url),
}
.set("Accept", "application/json");
if let Some(authorization) = request.authorization {
call = call.set("Authorization", &authorization);
}
let result = match method {
HttpMethod::Get => call.call(),
HttpMethod::Post => call
.set("Content-Type", "application/json")
.send_json(request.body.unwrap_or_else(|| json!({}))),
};
match result {
Ok(response) => response_from_ureq(response),
Err(ureq::Error::Status(status, response)) => response_from_status(status, response),
Err(err) => Err(NotforgeError::Http(format!(
"{method} {url} failed before response: {err}"
))),
}
}
}
#[derive(Debug, Deserialize)]
struct GiteaVersionResponse {
version: String,
}
#[derive(Debug, Deserialize)]
struct GiteaRepositoryResponse {
owner: GiteaOwnerResponse,
name: String,
clone_url: String,
ssh_url: String,
}
impl GiteaRepositoryResponse {
fn into_remote_repository(self) -> RemoteRepository {
RemoteRepository {
owner: self.owner.login,
name: self.name,
clone_url: self.clone_url,
ssh_url: self.ssh_url,
}
}
}
#[derive(Debug, Deserialize)]
struct GiteaOwnerResponse {
login: String,
}
#[derive(Debug, Serialize)]
struct CreateRepoRequest<'a> {
name: &'a str,
private: bool,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<&'a str>,
}
fn create_repo_body(spec: &RepoSpec) -> Value {
serde_json::to_value(CreateRepoRequest {
name: &spec.name,
private: spec.private,
description: spec.description.as_deref(),
})
.expect("CreateRepoRequest serialization should not fail")
}
fn authorization_header(auth: &ForgeAuth) -> String {
match auth {
ForgeAuth::Token { token } => format!("token {token}"),
ForgeAuth::Basic { username, password } => {
format!(
"Basic {}",
STANDARD.encode(format!("{username}:{password}"))
)
}
}
}
fn require_status(status: u16, expected: &[u16], action: &str) -> Result<(), NotforgeError> {
if expected.contains(&status) {
Ok(())
} else {
Err(NotforgeError::Http(format!(
"{action} returned unexpected status {status}"
)))
}
}
fn parse_body<T>(body: Value, context: &str) -> Result<T, NotforgeError>
where
T: for<'de> Deserialize<'de>,
{
serde_json::from_value(body)
.map_err(|err| NotforgeError::Http(format!("invalid {context}: {err}")))
}
fn response_from_status(
status: u16,
response: ureq::Response,
) -> Result<GiteaHttpResponse, NotforgeError> {
response_from_parts(status, response)
}
fn response_from_ureq(response: ureq::Response) -> Result<GiteaHttpResponse, NotforgeError> {
response_from_parts(response.status(), response)
}
fn response_from_parts(
status: u16,
response: ureq::Response,
) -> Result<GiteaHttpResponse, NotforgeError> {
let body = response
.into_string()
.map_err(|err| NotforgeError::Http(format!("reading HTTP response body: {err}")))?;
let body = if body.trim().is_empty() {
Value::Null
} else {
serde_json::from_str(&body)
.map_err(|err| NotforgeError::Http(format!("parsing HTTP response JSON: {err}")))?
};
Ok(GiteaHttpResponse { status, body })
}

View File

@@ -0,0 +1,19 @@
//! Forge lifecycle and repository provisioning for notfiles.
pub mod config;
pub mod error;
pub mod gitea;
pub mod ports;
/// Current `notforge` crate version.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exposes_package_version() {
assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
}
}

View File

@@ -0,0 +1,3 @@
fn main() {
println!("{}", notforge::VERSION);
}

View File

@@ -0,0 +1,126 @@
use std::path::PathBuf;
use crate::config::{ForgeConfig, ForgeSecretRef, RepoSpec};
use crate::error::NotforgeError;
/// Authentication material resolved for forge API requests.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ForgeAuth {
/// Token-based Gitea API authentication.
Token { token: String },
/// Basic authentication fallback for bootstrap flows.
Basic { username: String, password: String },
}
/// Remote repository metadata returned by a forge.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteRepository {
/// Repository owner namespace.
pub owner: String,
/// Repository name.
pub name: String,
/// HTTP clone URL.
pub clone_url: String,
/// SSH clone URL.
pub ssh_url: String,
}
/// Forge version metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForgeVersion {
/// Server version string.
pub version: String,
}
/// Local git repository path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalRepository {
/// Filesystem path to the local repository.
pub path: PathBuf,
}
/// Result of ensuring a forge backend is available.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LifecycleStatus {
/// The forge was already reachable.
AlreadyAvailable,
/// The forge was started by this operation.
Started,
/// The forge was verified without needing a start operation.
Verified,
}
/// Result of setting a local git remote.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteStatus {
/// The remote was added.
Added,
/// The remote existed and was updated.
Updated,
/// The remote already matched the desired URL.
Unchanged,
}
/// Result of pushing a local branch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PushStatus {
/// The branch was pushed.
Pushed,
/// The branch already matched the remote.
AlreadyUpToDate,
}
/// Port for forge API operations.
pub trait ForgeApi {
/// Return server version metadata.
fn version(&self) -> Result<ForgeVersion, NotforgeError>;
/// Look up a repository by owner and name.
fn repo(&self, owner: &str, name: &str) -> Result<Option<RemoteRepository>, NotforgeError>;
/// Create a repository from a spec and resolved auth.
fn create_repo(
&self,
spec: &RepoSpec,
auth: &ForgeAuth,
) -> Result<RemoteRepository, NotforgeError>;
}
/// Port for local or remote forge lifecycle operations.
pub trait ForgeLifecycle {
/// Ensure the configured forge backend is available.
fn ensure_available(&self, config: &ForgeConfig) -> Result<LifecycleStatus, NotforgeError>;
}
/// Port for git remote and push operations.
pub trait GitRemoteManager {
/// Return the URL for a local remote if it exists.
fn remote_url(
&self,
repo: &LocalRepository,
remote_name: &str,
) -> Result<Option<String>, NotforgeError>;
/// Add or update a local git remote.
fn set_remote(
&self,
repo: &LocalRepository,
remote_name: &str,
url: &str,
) -> Result<RemoteStatus, NotforgeError>;
/// Push a branch to a local remote.
fn push(
&self,
repo: &LocalRepository,
remote_name: &str,
branch: &str,
set_upstream: bool,
) -> Result<PushStatus, NotforgeError>;
}
/// Port for resolving forge secrets.
pub trait SecretResolverPort {
/// Resolve a secret reference into an in-memory secret value.
fn resolve(&self, secret: &ForgeSecretRef) -> Result<String, NotforgeError>;
}

View File

@@ -0,0 +1,222 @@
use std::path::PathBuf;
use notforge::config::{
ForgeAuthConfig, ForgeConfig, ForgeSecretRef, GiteaMode, RepoSpec, VmSystemdConfig, load_config,
};
use notforge::error::NotforgeError;
use notforge::ports::{
ForgeApi, ForgeAuth, ForgeLifecycle, ForgeVersion, GitRemoteManager, LifecycleStatus,
LocalRepository, PushStatus, RemoteRepository, RemoteStatus, SecretResolverPort,
};
fn assert_diagnostic<E: miette::Diagnostic>() {}
#[test]
fn notforge_error_implements_miette_diagnostic() {
assert_diagnostic::<NotforgeError>();
}
#[test]
fn config_parses_vm_systemd_gitea_mode() {
let dir = tempfile::TempDir::new().unwrap();
let config_path = dir.path().join("notforge.toml");
std::fs::write(
&config_path,
r#"
[gitea]
base_url = "http://gitea.local:3000"
owner = "joe"
ssh_host = "gitea.local"
ssh_port = 2222
[gitea.mode]
type = "vm-systemd"
[gitea.mode.config]
ssh_user = "dev"
ssh_host = "gitea.local"
service_name = "gitea"
[gitea.auth.token]
source = "env"
key = "GITEA_TOKEN"
[[repositories]]
owner = "joe"
name = "notfiles-config"
private = true
description = "Personal config payload"
remote_name = "gitea"
"#,
)
.unwrap();
let config = load_config(&config_path).unwrap();
assert_eq!(config.gitea.base_url, "http://gitea.local:3000");
assert_eq!(config.gitea.owner, "joe");
assert_eq!(config.gitea.ssh_port, 2222);
assert_eq!(
config.gitea.auth.token,
Some(ForgeSecretRef::Env {
key: "GITEA_TOKEN".to_string()
})
);
assert_eq!(
config.gitea.mode,
GiteaMode::VmSystemd(VmSystemdConfig {
ssh_user: "dev".to_string(),
ssh_host: "gitea.local".to_string(),
service_name: "gitea".to_string(),
})
);
assert_eq!(
config.repositories,
vec![RepoSpec {
owner: "joe".to_string(),
name: "notfiles-config".to_string(),
private: true,
description: Some("Personal config payload".to_string()),
remote_name: "gitea".to_string(),
}]
);
}
#[test]
fn config_defaults_repositories_to_empty() {
let config: ForgeConfig = toml::from_str(
r#"
[gitea]
base_url = "http://localhost:3000"
owner = "joe"
ssh_host = "localhost"
ssh_port = 2222
mode = "existing"
"#,
)
.unwrap();
assert!(config.repositories.is_empty());
assert_eq!(config.gitea.auth, ForgeAuthConfig::default());
}
#[test]
fn ports_are_object_safe_for_adapters() {
struct FakeApi;
impl ForgeApi for FakeApi {
fn version(&self) -> Result<ForgeVersion, NotforgeError> {
Ok(ForgeVersion {
version: "1.0.0".to_string(),
})
}
fn repo(
&self,
_owner: &str,
_name: &str,
) -> Result<Option<RemoteRepository>, NotforgeError> {
Ok(None)
}
fn create_repo(
&self,
spec: &RepoSpec,
_auth: &ForgeAuth,
) -> Result<RemoteRepository, NotforgeError> {
Ok(RemoteRepository {
owner: spec.owner.clone(),
name: spec.name.clone(),
clone_url: "http://example/repo.git".to_string(),
ssh_url: "ssh://git@example/joe/repo.git".to_string(),
})
}
}
struct FakeLifecycle;
impl ForgeLifecycle for FakeLifecycle {
fn ensure_available(
&self,
_config: &ForgeConfig,
) -> Result<LifecycleStatus, NotforgeError> {
Ok(LifecycleStatus::AlreadyAvailable)
}
}
struct FakeGit;
impl GitRemoteManager for FakeGit {
fn remote_url(
&self,
_repo: &LocalRepository,
_remote_name: &str,
) -> Result<Option<String>, NotforgeError> {
Ok(None)
}
fn set_remote(
&self,
_repo: &LocalRepository,
_remote_name: &str,
_url: &str,
) -> Result<RemoteStatus, NotforgeError> {
Ok(RemoteStatus::Added)
}
fn push(
&self,
_repo: &LocalRepository,
_remote_name: &str,
_branch: &str,
_set_upstream: bool,
) -> Result<PushStatus, NotforgeError> {
Ok(PushStatus::Pushed)
}
}
struct FakeSecrets;
impl SecretResolverPort for FakeSecrets {
fn resolve(&self, _secret: &ForgeSecretRef) -> Result<String, NotforgeError> {
Ok("secret".to_string())
}
}
let api: &dyn ForgeApi = &FakeApi;
let lifecycle: &dyn ForgeLifecycle = &FakeLifecycle;
let git: &dyn GitRemoteManager = &FakeGit;
let secrets: &dyn SecretResolverPort = &FakeSecrets;
assert_eq!(api.version().unwrap().version, "1.0.0");
assert_eq!(
lifecycle
.ensure_available(&ForgeConfig {
gitea: notforge::config::GiteaConfig {
base_url: "http://localhost:3000".to_string(),
owner: "joe".to_string(),
ssh_host: "localhost".to_string(),
ssh_port: 2222,
mode: GiteaMode::Existing,
auth: ForgeAuthConfig::default(),
},
repositories: Vec::new(),
})
.unwrap(),
LifecycleStatus::AlreadyAvailable
);
assert_eq!(
git.remote_url(
&LocalRepository {
path: PathBuf::from(".")
},
"gitea"
)
.unwrap(),
None
);
assert_eq!(
secrets
.resolve(&ForgeSecretRef::Env {
key: "GITEA_TOKEN".to_string()
})
.unwrap(),
"secret"
);
}

View File

@@ -0,0 +1,203 @@
use std::sync::{Arc, Mutex};
use notforge::config::RepoSpec;
use notforge::error::NotforgeError;
use notforge::gitea::{
GiteaHttpApi, GiteaHttpRequest, GiteaHttpResponse, GiteaTransport, HttpMethod,
};
use notforge::ports::{ForgeApi, ForgeAuth};
use serde_json::json;
#[derive(Debug, Clone)]
struct RecordedRequest {
method: HttpMethod,
url: String,
authorization: Option<String>,
body: Option<serde_json::Value>,
}
#[derive(Clone)]
struct FakeTransport {
requests: Arc<Mutex<Vec<RecordedRequest>>>,
responses: Arc<Mutex<Vec<Result<GiteaHttpResponse, NotforgeError>>>>,
}
impl FakeTransport {
fn new(responses: Vec<Result<GiteaHttpResponse, NotforgeError>>) -> Self {
Self {
requests: Arc::new(Mutex::new(Vec::new())),
responses: Arc::new(Mutex::new(responses)),
}
}
fn requests(&self) -> Vec<RecordedRequest> {
self.requests.lock().unwrap().clone()
}
}
impl GiteaTransport for FakeTransport {
fn send(&self, request: GiteaHttpRequest) -> Result<GiteaHttpResponse, NotforgeError> {
self.requests.lock().unwrap().push(RecordedRequest {
method: request.method,
url: request.url,
authorization: request.authorization,
body: request.body,
});
self.responses.lock().unwrap().remove(0)
}
}
#[test]
fn version_gets_gitea_server_version() {
let transport = FakeTransport::new(vec![Ok(GiteaHttpResponse {
status: 200,
body: json!({ "version": "1.22.4" }),
})]);
let api = GiteaHttpApi::with_transport("http://gitea.local:3000/", transport.clone());
let version = api.version().unwrap();
assert_eq!(version.version, "1.22.4");
let requests = transport.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, HttpMethod::Get);
assert_eq!(requests[0].url, "http://gitea.local:3000/api/v1/version");
assert_eq!(requests[0].authorization, None);
assert_eq!(requests[0].body, None);
}
#[test]
fn repo_maps_found_and_not_found_responses() {
let transport = FakeTransport::new(vec![
Ok(GiteaHttpResponse {
status: 200,
body: json!({
"owner": { "login": "joe" },
"name": "notfiles-config",
"clone_url": "http://gitea.local:3000/joe/notfiles-config.git",
"ssh_url": "ssh://git@gitea.local:2222/joe/notfiles-config.git"
}),
}),
Ok(GiteaHttpResponse {
status: 404,
body: json!({ "message": "not found" }),
}),
]);
let api = GiteaHttpApi::with_transport("http://gitea.local:3000", transport.clone());
let found = api.repo("joe", "notfiles-config").unwrap().unwrap();
let missing = api.repo("joe", "missing").unwrap();
assert_eq!(found.owner, "joe");
assert_eq!(found.name, "notfiles-config");
assert_eq!(
found.clone_url,
"http://gitea.local:3000/joe/notfiles-config.git"
);
assert_eq!(
found.ssh_url,
"ssh://git@gitea.local:2222/joe/notfiles-config.git"
);
assert_eq!(missing, None);
let requests = transport.requests();
assert_eq!(
requests[0].url,
"http://gitea.local:3000/api/v1/repos/joe/notfiles-config"
);
assert_eq!(
requests[1].url,
"http://gitea.local:3000/api/v1/repos/joe/missing"
);
}
#[test]
fn create_repo_posts_json_with_token_auth() {
let transport = FakeTransport::new(vec![Ok(GiteaHttpResponse {
status: 201,
body: json!({
"owner": { "login": "joe" },
"name": "notfiles-config",
"clone_url": "http://gitea.local:3000/joe/notfiles-config.git",
"ssh_url": "ssh://git@gitea.local:2222/joe/notfiles-config.git"
}),
})]);
let api = GiteaHttpApi::with_transport("http://gitea.local:3000", transport.clone());
let spec = RepoSpec {
owner: "joe".to_string(),
name: "notfiles-config".to_string(),
private: true,
description: Some("Personal config payload".to_string()),
remote_name: "gitea".to_string(),
};
let repo = api
.create_repo(
&spec,
&ForgeAuth::Token {
token: "redacted".to_string(),
},
)
.unwrap();
assert_eq!(repo.owner, "joe");
let requests = transport.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, HttpMethod::Post);
assert_eq!(requests[0].url, "http://gitea.local:3000/api/v1/user/repos");
assert_eq!(
requests[0].authorization,
Some("token redacted".to_string())
);
assert_eq!(
requests[0].body,
Some(json!({
"name": "notfiles-config",
"private": true,
"description": "Personal config payload"
}))
);
}
#[test]
fn create_repo_posts_json_with_basic_auth() {
let transport = FakeTransport::new(vec![Ok(GiteaHttpResponse {
status: 201,
body: json!({
"owner": { "login": "joe" },
"name": "notfiles-config",
"clone_url": "http://gitea.local:3000/joe/notfiles-config.git",
"ssh_url": "ssh://git@gitea.local:2222/joe/notfiles-config.git"
}),
})]);
let api = GiteaHttpApi::with_transport("http://gitea.local:3000", transport.clone());
let spec = RepoSpec {
owner: "joe".to_string(),
name: "notfiles-config".to_string(),
private: true,
description: None,
remote_name: "gitea".to_string(),
};
api.create_repo(
&spec,
&ForgeAuth::Basic {
username: "joe".to_string(),
password: "secret".to_string(),
},
)
.unwrap();
let requests = transport.requests();
assert_eq!(
requests[0].authorization,
Some("Basic am9lOnNlY3JldA==".to_string())
);
assert_eq!(
requests[0].body,
Some(json!({
"name": "notfiles-config",
"private": true
}))
);
}

View File

@@ -0,0 +1,25 @@
[package]
name = "notgraph"
version = "0.1.0"
edition = "2024"
license.workspace = true
[lib]
name = "notgraph_lib"
path = "src/lib.rs"
[[bin]]
name = "notgraph"
path = "src/main.rs"
[dev-dependencies]
tempfile = { workspace = true }
[dependencies]
anyhow = { workspace = true }
cargo_metadata = "0.18"
clap = { workspace = true }
serde = { workspace = true }
serde_json = "1"
syn = { version = "2", features = ["full", "visit"] }
walkdir = "2"

View File

@@ -0,0 +1,205 @@
use crate::types::{CrateGraph, FanStats, GraphStats, Hotspot, HotspotKind, ModStats, ModuleGraph};
use std::cmp::Reverse;
use std::collections::{HashMap, VecDeque};
pub fn fan_stats(nodes: &[String], edges: &[(String, String)]) -> Vec<FanStats> {
let mut fan_in: HashMap<&str, usize> = nodes.iter().map(|n| (n.as_str(), 0)).collect();
let mut fan_out: HashMap<&str, usize> = nodes.iter().map(|n| (n.as_str(), 0)).collect();
for (from, to) in edges {
*fan_out.entry(from.as_str()).or_insert(0) += 1;
*fan_in.entry(to.as_str()).or_insert(0) += 1;
}
nodes
.iter()
.map(|n| FanStats {
name: n.clone(),
fan_in: *fan_in.get(n.as_str()).unwrap_or(&0),
fan_out: *fan_out.get(n.as_str()).unwrap_or(&0),
})
.collect()
}
/// Kahn's algorithm. Returns cycle participants if graph is not a DAG.
pub fn detect_cycles(nodes: &[String], edges: &[(String, String)]) -> Vec<Vec<String>> {
let mut in_degree: HashMap<&str, usize> = nodes.iter().map(|n| (n.as_str(), 0)).collect();
let mut adj: HashMap<&str, Vec<&str>> = nodes.iter().map(|n| (n.as_str(), vec![])).collect();
let node_set: std::collections::HashSet<&str> = nodes.iter().map(|n| n.as_str()).collect();
for (from, to) in edges {
if node_set.contains(from.as_str()) && node_set.contains(to.as_str()) {
*in_degree.entry(to.as_str()).or_insert(0) += 1;
adj.entry(from.as_str()).or_default().push(to.as_str());
}
}
let mut queue: VecDeque<&str> = in_degree
.iter()
.filter(|&(_, &d)| d == 0)
.map(|(n, _)| *n)
.collect();
let mut visited = 0usize;
while let Some(node) = queue.pop_front() {
visited += 1;
for &neighbour in adj.get(node).unwrap_or(&vec![]) {
let deg = in_degree.entry(neighbour).or_insert(0);
*deg -= 1;
if *deg == 0 {
queue.push_back(neighbour);
}
}
}
if visited == nodes.len() {
return vec![];
}
let cycle_nodes: Vec<String> = in_degree
.iter()
.filter(|&(_, &d)| d > 0)
.map(|(n, _)| n.to_string())
.collect();
vec![cycle_nodes]
}
pub fn hotspots(stats: &[FanStats], top_n: usize) -> Vec<Hotspot> {
let mut result = Vec::new();
let mut by_fan_in = stats.to_vec();
by_fan_in.sort_by_key(|b| Reverse(b.fan_in));
for s in by_fan_in.iter().take(top_n) {
if s.fan_in > 0 {
result.push(Hotspot {
name: s.name.clone(),
kind: HotspotKind::FanIn,
score: s.fan_in,
});
}
}
let mut by_fan_out = stats.to_vec();
by_fan_out.sort_by_key(|b| Reverse(b.fan_out));
for s in by_fan_out.iter().take(top_n) {
if s.fan_out > 0 {
result.push(Hotspot {
name: s.name.clone(),
kind: HotspotKind::FanOut,
score: s.fan_out,
});
}
}
result
}
pub fn analyse(
crate_graph: &CrateGraph,
module_graphs: &[ModuleGraph],
top_n: usize,
) -> GraphStats {
let crate_stats = fan_stats(&crate_graph.nodes, &crate_graph.edges);
let crate_hotspots = hotspots(&crate_stats, top_n);
let mut all_cycles: Vec<Vec<String>> = Vec::new();
let mut mod_stats_list: Vec<ModStats> = Vec::new();
for mg in module_graphs {
let node_stats = fan_stats(&mg.nodes, &mg.edges);
let cycles = detect_cycles(&mg.nodes, &mg.edges);
all_cycles.extend(cycles.clone());
mod_stats_list.push(ModStats {
krate: mg.krate.clone(),
nodes: node_stats,
cycles,
});
}
let all_mod_fan: Vec<FanStats> = mod_stats_list
.iter()
.flat_map(|ms| ms.nodes.iter().cloned())
.collect();
let mod_hotspots = hotspots(&all_mod_fan, top_n);
let mut all_hotspots = crate_hotspots;
all_hotspots.extend(mod_hotspots);
GraphStats {
crate_graph: crate_stats,
module_graphs: mod_stats_list,
hotspots: all_hotspots,
cycles: all_cycles,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fan_stats_counts_correctly() {
let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let edges = vec![
("a".to_string(), "b".to_string()),
("a".to_string(), "c".to_string()),
("b".to_string(), "c".to_string()),
];
let stats = fan_stats(&nodes, &edges);
let a = stats.iter().find(|s| s.name == "a").unwrap();
let c = stats.iter().find(|s| s.name == "c").unwrap();
assert_eq!(a.fan_out, 2);
assert_eq!(a.fan_in, 0);
assert_eq!(c.fan_in, 2);
assert_eq!(c.fan_out, 0);
}
#[test]
fn no_cycle_in_acyclic_graph() {
let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let edges = vec![
("a".to_string(), "b".to_string()),
("b".to_string(), "c".to_string()),
];
assert!(detect_cycles(&nodes, &edges).is_empty());
}
#[test]
fn detects_cycle_in_cyclic_graph() {
let nodes = vec!["a".to_string(), "b".to_string()];
let edges = vec![
("a".to_string(), "b".to_string()),
("b".to_string(), "a".to_string()),
];
let cycles = detect_cycles(&nodes, &edges);
assert!(!cycles.is_empty());
}
#[test]
fn hotspots_picks_top_n_by_fan_in_and_fan_out() {
let stats = vec![
FanStats {
name: "a".to_string(),
fan_in: 5,
fan_out: 1,
},
FanStats {
name: "b".to_string(),
fan_in: 3,
fan_out: 2,
},
FanStats {
name: "c".to_string(),
fan_in: 1,
fan_out: 4,
},
];
let spots = hotspots(&stats, 1);
let fi = spots.iter().find(|h| h.kind == HotspotKind::FanIn).unwrap();
let fo = spots
.iter()
.find(|h| h.kind == HotspotKind::FanOut)
.unwrap();
assert_eq!(fi.name, "a");
assert_eq!(fo.name, "c");
}
}

View File

@@ -0,0 +1,59 @@
use crate::types::{CrateGraph, CrateName};
use anyhow::Result;
pub fn build(manifest_path: &std::path::Path) -> Result<CrateGraph> {
let meta = cargo_metadata::MetadataCommand::new()
.manifest_path(manifest_path)
.no_deps()
.exec()?;
let workspace_members: std::collections::HashSet<String> = meta
.workspace_members
.iter()
.filter_map(|id| meta.packages.iter().find(|p| &p.id == id))
.map(|p| p.name.clone())
.collect();
let nodes: Vec<CrateName> = workspace_members.iter().cloned().collect();
let mut edges: Vec<(CrateName, CrateName)> = Vec::new();
for pkg in meta
.packages
.iter()
.filter(|p| workspace_members.contains(&p.name))
{
for dep in &pkg.dependencies {
if workspace_members.contains(&dep.name) {
edges.push((pkg.name.clone(), dep.name.clone()));
}
}
}
Ok(CrateGraph { nodes, edges })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_crate_graph_for_workspace() {
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("Cargo.toml");
let graph = build(&manifest).unwrap();
assert!(graph.nodes.contains(&"notgraph".to_string()));
assert!(graph.nodes.contains(&"notcore".to_string()));
assert!(
graph
.edges
.contains(&("notstrap".to_string(), "notfiles".to_string()))
);
assert!(!graph.edges.iter().any(|(from, _)| from == "notcore"));
}
}

357
crates/notgraph/src/emit.rs Normal file
View File

@@ -0,0 +1,357 @@
use crate::types::{CrateGraph, GraphStats, HotspotKind, ModuleGraph, SymbolKind, SymbolTable};
use anyhow::Result;
use std::path::Path;
pub fn write_all(
output_dir: &Path,
crate_graph: &CrateGraph,
module_graphs: &[ModuleGraph],
stats: &GraphStats,
symbol_tables: &[SymbolTable],
) -> Result<()> {
std::fs::create_dir_all(output_dir)?;
write_json(output_dir, stats)?;
write_markdown(output_dir, stats, symbol_tables)?;
write_html(output_dir, crate_graph, module_graphs, stats, symbol_tables)?;
Ok(())
}
fn write_json(dir: &Path, stats: &GraphStats) -> Result<()> {
std::fs::write(
dir.join("report.json"),
serde_json::to_string_pretty(stats)?,
)?;
Ok(())
}
fn write_markdown(dir: &Path, stats: &GraphStats, symbol_tables: &[SymbolTable]) -> Result<()> {
let mut md = String::new();
md.push_str("# notgraph Report\n\n");
md.push_str("## Crate Dependency Graph\n\n");
for fs in &stats.crate_graph {
md.push_str(&format!(
"- **{}** (fan-in: {}, fan-out: {})\n",
fs.name, fs.fan_in, fs.fan_out
));
}
md.push('\n');
md.push_str("## Module Graphs\n\n");
for ms in &stats.module_graphs {
let status = if ms.cycles.is_empty() {
"OK"
} else {
"CYCLES DETECTED"
};
md.push_str(&format!(
"### {} [{}]\n\n- Modules: {}\n- Cycles: {}\n\n",
ms.krate,
status,
ms.nodes.len(),
ms.cycles.len()
));
}
md.push_str("## Hotspots\n\n### Fan-in (most depended-upon)\n\n");
md.push_str("| Name | Score |\n|------|-------|\n");
for h in stats
.hotspots
.iter()
.filter(|h| h.kind == HotspotKind::FanIn)
{
md.push_str(&format!("| {} | {} |\n", h.name, h.score));
}
md.push('\n');
md.push_str("### Fan-out (highest coupling)\n\n");
md.push_str("| Name | Score |\n|------|-------|\n");
for h in stats
.hotspots
.iter()
.filter(|h| h.kind == HotspotKind::FanOut)
{
md.push_str(&format!("| {} | {} |\n", h.name, h.score));
}
md.push('\n');
md.push_str("## Public Symbol Inventory\n\n");
for table in symbol_tables {
md.push_str(&format!("### {}\n\n", table.krate));
for kind in [
SymbolKind::Struct,
SymbolKind::Enum,
SymbolKind::Trait,
SymbolKind::Fn,
SymbolKind::Type,
SymbolKind::Const,
] {
let syms: Vec<&str> = table
.symbols
.iter()
.filter(|s| s.kind == kind && s.is_pub)
.map(|s| s.name.as_str())
.collect();
if !syms.is_empty() {
md.push_str(&format!("**{}**: {}\n\n", kind, syms.join(", ")));
}
}
}
md.push_str("## Cycle Report\n\n");
if stats.cycles.is_empty() {
md.push_str("No cycles detected.\n");
} else {
for cycle in &stats.cycles {
md.push_str(&format!("- CYCLE: {}\n", cycle.join(" -> ")));
}
}
std::fs::write(dir.join("report.md"), md)?;
Ok(())
}
fn write_html(
dir: &Path,
crate_graph: &CrateGraph,
module_graphs: &[ModuleGraph],
stats: &GraphStats,
symbol_tables: &[SymbolTable],
) -> Result<()> {
let fan_map: std::collections::HashMap<&str, (usize, usize)> = stats
.crate_graph
.iter()
.map(|fs| (fs.name.as_str(), (fs.fan_in, fs.fan_out)))
.collect();
// Symbol counts per crate
let sym_count: std::collections::HashMap<&str, usize> = symbol_tables
.iter()
.map(|t| {
let count = t.symbols.iter().filter(|s| s.is_pub).count();
(t.krate.as_str(), count)
})
.collect();
// Nodes involved in any cycle (for red highlighting)
let cycle_nodes: std::collections::HashSet<&str> = stats
.cycles
.iter()
.flat_map(|c| c.iter().map(|n| n.as_str()))
.collect();
// Max fan-in for heatmap normalisation
let max_fan_in = stats
.crate_graph
.iter()
.map(|fs| fs.fan_in)
.max()
.unwrap_or(1)
.max(1);
// Heatmap: interpolate between dim blue and bright blue based on fan-in
let heatmap_color = |fan_in: usize| -> String {
let t = fan_in as f32 / max_fan_in as f32;
let r = (30.0 + t * 20.0) as u8;
let g = (58.0 + t * 80.0) as u8;
let b = (95.0 + t * 160.0) as u8;
format!("#{:02x}{:02x}{:02x}", r, g, b)
};
// Classify nodes into subgraphs by topology
let leaf_nodes: std::collections::HashSet<&str> = stats
.crate_graph
.iter()
.filter(|fs| fs.fan_out == 0)
.map(|fs| fs.name.as_str())
.collect();
let root_nodes: std::collections::HashSet<&str> = stats
.crate_graph
.iter()
.filter(|fs| fs.fan_in == 0)
.map(|fs| fs.name.as_str())
.collect();
let mut leaves: Vec<&str> = crate_graph
.nodes
.iter()
.map(|n| n.as_str())
.filter(|n| leaf_nodes.contains(n) && !root_nodes.contains(n))
.collect();
let mut roots: Vec<&str> = crate_graph
.nodes
.iter()
.map(|n| n.as_str())
.filter(|n| root_nodes.contains(n) && !leaf_nodes.contains(n))
.collect();
let mut features: Vec<&str> = crate_graph
.nodes
.iter()
.map(|n| n.as_str())
.filter(|n| !leaf_nodes.contains(n) && !root_nodes.contains(n))
.collect();
let mut isolated: Vec<&str> = crate_graph
.nodes
.iter()
.map(|n| n.as_str())
.filter(|n| leaf_nodes.contains(n) && root_nodes.contains(n))
.collect();
leaves.sort();
roots.sort();
features.sort();
isolated.sort();
roots.extend(isolated);
// Sanitize crate name for use as a Mermaid node ID (hyphens are not valid)
let mermaid_id = |n: &str| n.replace('-', "_");
let node_label = |n: &str| -> String {
let (fi, fo) = fan_map.get(n).copied().unwrap_or((0, 0));
let syms = sym_count.get(n).copied().unwrap_or(0);
format!(
"{}[\"{}<br/>in:{} out:{} | {} pub\"]",
mermaid_id(n),
n,
fi,
fo,
syms
)
};
// ── Crate dependency graph ──────────────────────────────────────────────
let mut mermaid = String::from("flowchart LR\n");
if !leaves.is_empty() {
mermaid.push_str(" subgraph Core\n direction TB\n");
for n in &leaves {
mermaid.push_str(&format!(" {}\n", node_label(n)));
}
mermaid.push_str(" end\n");
}
if !features.is_empty() {
mermaid.push_str(" subgraph Features\n direction TB\n");
for n in &features {
mermaid.push_str(&format!(" {}\n", node_label(n)));
}
mermaid.push_str(" end\n");
}
if !roots.is_empty() {
mermaid.push_str(" subgraph Tools\n direction TB\n");
for n in &roots {
mermaid.push_str(&format!(" {}\n", node_label(n)));
}
mermaid.push_str(" end\n");
}
for (from, to) in &crate_graph.edges {
mermaid.push_str(&format!(" {} --> {}\n", mermaid_id(to), mermaid_id(from)));
}
// Heatmap styles
for fs in &stats.crate_graph {
let color = heatmap_color(fs.fan_in);
mermaid.push_str(&format!(
" style {} fill:{},stroke:#4a9eff,color:#e0e0e0\n",
mermaid_id(&fs.name),
color
));
}
// Cycle highlights (override heatmap)
for n in &cycle_nodes {
mermaid.push_str(&format!(
" style {} fill:#7a1a1a,stroke:#f44336,color:#ffffff\n",
mermaid_id(n)
));
}
let graph_diagram = mermaid;
// Build cycle lookup from stats (ModuleGraph itself has no cycle data)
let mod_cycles: std::collections::HashMap<&str, &Vec<Vec<String>>> = stats
.module_graphs
.iter()
.map(|ms| (ms.krate.as_str(), &ms.cycles))
.collect();
// ── Per-crate module graphs ─────────────────────────────────────────────
let mut module_diagrams = String::new();
for mg in module_graphs {
// Collect cycle nodes for this crate
let empty: Vec<Vec<String>> = Vec::new();
let cycles = mod_cycles.get(mg.krate.as_str()).copied().unwrap_or(&empty);
let crate_cycle_nodes: std::collections::HashSet<&str> = cycles
.iter()
.flat_map(|c| c.iter().map(|n| n.as_str()))
.collect();
let mut d = "flowchart TD\n".to_string();
// Nodes: strip crate prefix for readable labels, skip ::tests modules
for node in &mg.nodes {
let short = node
.strip_prefix(&format!("{}::", mg.krate))
.unwrap_or(node.as_str());
// Sanitize id for Mermaid (replace :: with __)
// Also sanitize label — :: inside quotes triggers a Mermaid parse error
let id = node.replace("::", "__");
let label = short.replace("::", "/");
d.push_str(&format!(" {}[\"{}\"]\n", id, label));
}
// Edges
for (from, to) in &mg.edges {
let from_id = from.replace("::", "__");
let to_id = to.replace("::", "__");
d.push_str(&format!(" {} --> {}\n", from_id, to_id));
}
// Cycle highlights
for n in &crate_cycle_nodes {
let id = n.replace("::", "__");
d.push_str(&format!(
" style {} fill:#7a1a1a,stroke:#f44336,color:#ffffff\n",
id
));
}
module_diagrams.push_str(&format!(
"<h3>{}</h3>\n<div class=\"graph-wrap\"><pre class=\"mermaid\">\n%%{{init: {{\"theme\":\"dark\"}}}}%%\n{}</pre></div>\n",
mg.krate, d
));
}
let cycle_html = if stats.cycles.is_empty() {
"<p>No cycles detected.</p>".to_string()
} else {
stats
.cycles
.iter()
.map(|c| format!("<p class=\"cycle-entry\">CYCLE: {}</p>", c.join(" &rarr; ")))
.collect::<Vec<_>>()
.join("\n")
};
let hotspot_rows: String = stats
.hotspots
.iter()
.map(|h| {
format!(
"<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
h.name, h.kind, h.score
)
})
.collect::<Vec<_>>()
.join("\n");
let html = format!(
include_str!("../templates/report.html.template"),
graph_diagram = graph_diagram,
module_diagrams = module_diagrams,
hotspot_rows = hotspot_rows,
cycle_html = cycle_html,
);
std::fs::write(dir.join("report.html"), html)?;
Ok(())
}

View File

@@ -0,0 +1,6 @@
pub mod analysis;
pub mod crate_graph;
pub mod emit;
pub mod module_graph;
pub mod symbols;
pub mod types;

100
crates/notgraph/src/main.rs Normal file
View File

@@ -0,0 +1,100 @@
use anyhow::{Context, Result};
use clap::Parser;
use notgraph_lib::{analysis, crate_graph, emit, module_graph, symbols};
use std::path::PathBuf;
#[derive(Parser)]
#[command(
name = "notgraph",
about = "Workspace module graph and symbol analysis"
)]
struct Cli {
#[arg(long, default_value = "docs/graph")]
output: PathBuf,
#[arg(long)]
fail_on_cycles: bool,
#[arg(long, default_value_t = 10)]
top: usize,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let manifest = find_workspace_manifest()?;
let workspace_root = manifest.parent().unwrap().to_path_buf();
let crate_g = crate_graph::build(&manifest).context("failed to build crate graph")?;
let meta = cargo_metadata::MetadataCommand::new()
.manifest_path(&manifest)
.no_deps()
.exec()?;
let mut module_graphs = Vec::new();
let mut symbol_tables = Vec::new();
for pkg in &meta.packages {
if !meta.workspace_members.contains(&pkg.id) {
continue;
}
let src_dir = PathBuf::from(pkg.manifest_path.as_str())
.parent()
.unwrap()
.join("src");
if !src_dir.exists() {
continue;
}
module_graphs.push(
module_graph::build(pkg.name.clone(), &src_dir)
.with_context(|| format!("module_graph failed for {}", pkg.name))?,
);
symbol_tables.push(
symbols::build(pkg.name.clone(), &src_dir)
.with_context(|| format!("symbols failed for {}", pkg.name))?,
);
}
let stats = analysis::analyse(&crate_g, &module_graphs, cli.top);
let output_dir = if cli.output.is_absolute() {
cli.output.clone()
} else {
workspace_root.join(&cli.output)
};
emit::write_all(
&output_dir,
&crate_g,
&module_graphs,
&stats,
&symbol_tables,
)
.context("failed to write reports")?;
println!("notgraph: reports written to {}", output_dir.display());
if cli.fail_on_cycles && !stats.cycles.is_empty() {
eprintln!("notgraph: {} cycle(s) detected", stats.cycles.len());
for cycle in &stats.cycles {
eprintln!(" {}", cycle.join(" -> "));
}
std::process::exit(1);
}
Ok(())
}
fn find_workspace_manifest() -> Result<PathBuf> {
let mut dir = std::env::current_dir()?;
loop {
let candidate = dir.join("Cargo.toml");
if candidate.exists() {
let content = std::fs::read_to_string(&candidate)?;
if content.contains("[workspace]") {
return Ok(candidate);
}
}
anyhow::ensure!(dir.pop(), "could not find workspace Cargo.toml");
}
}

View File

@@ -0,0 +1,102 @@
use crate::types::{CrateName, ModPath, ModuleGraph};
use anyhow::Result;
use std::path::Path;
use syn::visit::Visit;
use walkdir::WalkDir;
struct ModCollector {
current_mod: ModPath,
edges: Vec<(ModPath, ModPath)>,
nodes: Vec<ModPath>,
}
impl ModCollector {
fn new(root_mod: ModPath) -> Self {
Self {
current_mod: root_mod.clone(),
edges: Vec::new(),
nodes: vec![root_mod],
}
}
}
impl<'ast> Visit<'ast> for ModCollector {
fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
let child = format!("{}::{}", self.current_mod, node.ident);
if !self.nodes.contains(&child) {
self.nodes.push(child.clone());
}
self.edges.push((self.current_mod.clone(), child.clone()));
let parent = std::mem::replace(&mut self.current_mod, child);
syn::visit::visit_item_mod(self, node);
self.current_mod = parent;
}
}
pub fn build(krate: CrateName, src_dir: &Path) -> Result<ModuleGraph> {
let mut all_nodes: Vec<ModPath> = Vec::new();
let mut all_edges: Vec<(ModPath, ModPath)> = Vec::new();
for entry in WalkDir::new(src_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "rs"))
{
let path = entry.path();
let content = std::fs::read_to_string(path)?;
let file = syn::parse_file(&content)?;
let rel = path.strip_prefix(src_dir)?;
let mod_path = crate::types::path_to_mod(&krate, rel);
let mut collector = ModCollector::new(mod_path);
collector.visit_file(&file);
for node in collector.nodes {
if !all_nodes.contains(&node) {
all_nodes.push(node);
}
}
all_edges.extend(collector.edges);
}
all_edges.sort();
all_edges.dedup();
Ok(ModuleGraph {
krate,
nodes: all_nodes,
edges: all_edges,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clean_fixture_has_expected_nodes() {
let fixture =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/clean/src");
let graph = build("clean".to_string(), &fixture).unwrap();
assert!(graph.nodes.contains(&"clean".to_string()));
assert!(graph.nodes.contains(&"clean::alpha".to_string()));
assert!(graph.nodes.contains(&"clean::beta".to_string()));
}
#[test]
fn clean_fixture_has_edges_from_lib() {
let fixture =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/clean/src");
let graph = build("clean".to_string(), &fixture).unwrap();
assert!(
graph
.edges
.contains(&("clean".to_string(), "clean::alpha".to_string()))
);
assert!(
graph
.edges
.contains(&("clean".to_string(), "clean::beta".to_string()))
);
}
}

View File

@@ -0,0 +1,180 @@
use crate::types::{CrateName, ModPath, Symbol, SymbolKind, SymbolTable};
use anyhow::Result;
use std::path::Path;
use syn::{Visibility, visit::Visit};
use walkdir::WalkDir;
struct SymbolCollector {
mod_path: ModPath,
symbols: Vec<Symbol>,
}
impl SymbolCollector {
fn new(mod_path: ModPath) -> Self {
Self {
mod_path,
symbols: Vec::new(),
}
}
fn is_pub(vis: &Visibility) -> bool {
matches!(vis, Visibility::Public(_))
}
fn push(&mut self, kind: SymbolKind, name: String, is_pub: bool) {
self.symbols.push(Symbol {
mod_path: self.mod_path.clone(),
kind,
name,
is_pub,
});
}
}
impl<'ast> Visit<'ast> for SymbolCollector {
fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) {
self.push(
SymbolKind::Struct,
node.ident.to_string(),
Self::is_pub(&node.vis),
);
}
fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
self.push(
SymbolKind::Enum,
node.ident.to_string(),
Self::is_pub(&node.vis),
);
}
fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) {
self.push(
SymbolKind::Trait,
node.ident.to_string(),
Self::is_pub(&node.vis),
);
}
fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
self.push(
SymbolKind::Fn,
node.sig.ident.to_string(),
Self::is_pub(&node.vis),
);
}
fn visit_item_type(&mut self, node: &'ast syn::ItemType) {
self.push(
SymbolKind::Type,
node.ident.to_string(),
Self::is_pub(&node.vis),
);
}
fn visit_item_const(&mut self, node: &'ast syn::ItemConst) {
self.push(
SymbolKind::Const,
node.ident.to_string(),
Self::is_pub(&node.vis),
);
}
fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
let child_path = format!("{}::{}", self.mod_path, node.ident);
let parent = std::mem::replace(&mut self.mod_path, child_path);
syn::visit::visit_item_mod(self, node);
self.mod_path = parent;
}
}
pub fn build(krate: CrateName, src_dir: &Path) -> Result<SymbolTable> {
let mut symbols: Vec<Symbol> = Vec::new();
for entry in WalkDir::new(src_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "rs"))
{
let path = entry.path();
let content = std::fs::read_to_string(path)?;
let file = syn::parse_file(&content)?;
let rel = path.strip_prefix(src_dir)?;
let mod_path = crate::types::path_to_mod(&krate, rel);
let mut collector = SymbolCollector::new(mod_path);
collector.visit_file(&file);
symbols.extend(collector.symbols);
}
Ok(SymbolTable { krate, symbols })
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_table() -> SymbolTable {
let fixture =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/symbols/src");
build("symbols".to_string(), &fixture).unwrap()
}
#[test]
fn detects_pub_struct() {
let t = fixture_table();
assert!(
t.symbols
.iter()
.any(|s| s.name == "Foo" && s.kind == SymbolKind::Struct && s.is_pub)
);
}
#[test]
fn detects_pub_enum() {
let t = fixture_table();
assert!(
t.symbols
.iter()
.any(|s| s.name == "Bar" && s.kind == SymbolKind::Enum && s.is_pub)
);
}
#[test]
fn detects_pub_trait() {
let t = fixture_table();
assert!(
t.symbols
.iter()
.any(|s| s.name == "Baz" && s.kind == SymbolKind::Trait && s.is_pub)
);
}
#[test]
fn detects_pub_fn() {
let t = fixture_table();
assert!(
t.symbols
.iter()
.any(|s| s.name == "qux" && s.kind == SymbolKind::Fn && s.is_pub)
);
}
#[test]
fn detects_pub_type_alias() {
let t = fixture_table();
assert!(
t.symbols
.iter()
.any(|s| s.name == "Alias" && s.kind == SymbolKind::Type && s.is_pub)
);
}
#[test]
fn detects_pub_const() {
let t = fixture_table();
assert!(
t.symbols
.iter()
.any(|s| s.name == "VALUE" && s.kind == SymbolKind::Const && s.is_pub)
);
}
#[test]
fn private_struct_is_not_pub() {
let t = fixture_table();
let p = t.symbols.iter().find(|s| s.name == "Private").unwrap();
assert!(!p.is_pub);
}
}

View File

@@ -0,0 +1,114 @@
use serde::{Deserialize, Serialize};
pub type CrateName = String;
pub type ModPath = String;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrateGraph {
pub nodes: Vec<CrateName>,
pub edges: Vec<(CrateName, CrateName)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModuleGraph {
pub krate: CrateName,
pub nodes: Vec<ModPath>,
pub edges: Vec<(ModPath, ModPath)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Symbol {
pub mod_path: ModPath,
pub kind: SymbolKind,
pub name: String,
pub is_pub: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum SymbolKind {
Struct,
Enum,
Trait,
Fn,
Type,
Const,
}
impl std::fmt::Display for SymbolKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SymbolKind::Struct => write!(f, "struct"),
SymbolKind::Enum => write!(f, "enum"),
SymbolKind::Trait => write!(f, "trait"),
SymbolKind::Fn => write!(f, "fn"),
SymbolKind::Type => write!(f, "type"),
SymbolKind::Const => write!(f, "const"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SymbolTable {
pub krate: CrateName,
pub symbols: Vec<Symbol>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FanStats {
pub name: String,
pub fan_in: usize,
pub fan_out: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModStats {
pub krate: CrateName,
pub nodes: Vec<FanStats>,
pub cycles: Vec<Vec<ModPath>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum HotspotKind {
FanIn,
FanOut,
}
impl std::fmt::Display for HotspotKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HotspotKind::FanIn => write!(f, "fan-in"),
HotspotKind::FanOut => write!(f, "fan-out"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hotspot {
pub name: String,
pub kind: HotspotKind,
pub score: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphStats {
pub crate_graph: Vec<FanStats>,
pub module_graphs: Vec<ModStats>,
pub hotspots: Vec<Hotspot>,
pub cycles: Vec<Vec<ModPath>>,
}
/// Convert a source file path (relative to src/) to a Rust module path like `crate::foo::bar`.
pub fn path_to_mod(krate: &str, rel: &std::path::Path) -> ModPath {
let mut parts: Vec<String> = vec![krate.to_string()];
for component in rel.components() {
let s = component.as_os_str().to_string_lossy();
if s == "lib.rs" || s == "main.rs" {
break;
}
let s = s.trim_end_matches(".rs").to_string();
if s != "mod" {
parts.push(s);
}
}
parts.join("::")
}

View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>notgraph Report</title>
<script src="https://unpkg.com/mermaid@11/dist/mermaid.min.js"></script>
<style>
:root {{
--bg: #1a1a1a;
--bg2: #242424;
--bg3: #2e2e2e;
--border: #3a3a3a;
--text: #e0e0e0;
--accent: #4a9eff;
--heading-border: #4a9eff;
--cycle-bg: #3a1a1a;
--cycle-text: #f44336;
}}
* {{ box-sizing: border-box; }}
body {{ font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 2rem; max-width: 1200px; }}
h1 {{ color: var(--accent); margin-bottom: 0.5rem; }}
h2 {{ border-bottom: 2px solid var(--heading-border); padding-bottom: 0.3rem; color: var(--text); margin-top: 2.5rem; }}
h3 {{ color: var(--accent); margin-top: 1.5rem; margin-bottom: 0.5rem; }}
.graph-wrap {{ background: var(--bg2); border: 1px solid var(--border); border-radius: 4px; padding: 1.5rem; margin-bottom: 1.5rem; overflow-x: auto; }}
table {{ border-collapse: collapse; width: 100%; margin-bottom: 2rem; }}
th, td {{ border: 1px solid var(--border); padding: 0.4rem 0.8rem; text-align: left; }}
th {{ background: var(--bg3); cursor: pointer; user-select: none; color: var(--accent); }}
th:hover {{ background: var(--border); }}
td {{ background: var(--bg2); }}
tr:hover td {{ background: var(--bg3); }}
p {{ color: var(--text); }}
.cycle-entry {{ background: var(--cycle-bg); color: var(--cycle-text); padding: 0.4rem 0.8rem; border-left: 3px solid var(--cycle-text); border-radius: 2px; font-family: monospace; }}
.legend {{ display: flex; gap: 1.5rem; margin-bottom: 1rem; font-size: 0.85rem; color: #888; }}
.legend-item {{ display: flex; align-items: center; gap: 0.4rem; }}
.legend-swatch {{ width: 14px; height: 14px; border-radius: 2px; border: 1px solid #4a9eff; }}
</style>
</head>
<body>
<h1>notgraph Report</h1>
<h2>Crate Dependency Graph</h2>
<div class="legend">
<div class="legend-item"><div class="legend-swatch" style="background:#1e3a5f"></div> low fan-in</div>
<div class="legend-item"><div class="legend-swatch" style="background:#1e72ff"></div> high fan-in</div>
<div class="legend-item"><div class="legend-swatch" style="background:#7a1a1a;border-color:#f44336"></div> cycle</div>
</div>
<div class="graph-wrap">
<pre class="mermaid">
%%{{init: {{'theme':'dark','themeVariables':{{'darkMode':true,'background':'#242424','primaryColor':'#1e3a5f','primaryBorderColor':'#4a9eff','primaryTextColor':'#e0e0e0','lineColor':'#4a9eff','edgeLabelBackground':'#242424'}}}}}}%%
{graph_diagram}</pre>
</div>
<h2>Module Graphs</h2>
{module_diagrams}
<h2>Hotspots</h2>
<table id="hotspots">
<thead><tr>
<th onclick="sortTable('hotspots',0,this)">Name</th>
<th onclick="sortTable('hotspots',1,this)">Kind</th>
<th onclick="sortTable('hotspots',2,this)">Score</th>
</tr></thead>
<tbody>{hotspot_rows}</tbody>
</table>
<h2>Cycle Report</h2>
{cycle_html}
<script>
mermaid.initialize({{ startOnLoad: true }});
function sortTable(id, col, th) {{
const t = document.getElementById(id);
const asc = th.dataset.dir !== 'asc';
th.dataset.dir = asc ? 'asc' : 'desc';
const rows = Array.from(t.querySelectorAll('tbody tr'));
rows.sort((a, b) => {{
const av = a.cells[col].textContent.trim();
const bv = b.cells[col].textContent.trim();
const cmp = av.localeCompare(bv, undefined, {{numeric: true}});
return asc ? cmp : -cmp;
}});
const tbody = t.querySelector('tbody');
rows.forEach(r => tbody.appendChild(r));
}}
</script>
</body>
</html>

View File

@@ -0,0 +1 @@
pub fn hello() -> &'static str { "alpha" }

View File

@@ -0,0 +1 @@
pub fn greet() -> &'static str { "beta" }

View File

@@ -0,0 +1,2 @@
pub mod alpha;
pub mod beta;

View File

@@ -0,0 +1 @@
pub mod foo;

View File

@@ -0,0 +1 @@
pub mod bar;

View File

@@ -0,0 +1,2 @@
pub mod foo;
pub mod bar;

View File

@@ -0,0 +1,7 @@
pub struct Foo;
pub enum Bar { A, B }
pub trait Baz { fn run(&self); }
pub fn qux() {}
pub type Alias = u32;
pub const VALUE: u32 = 42;
struct Private;

View File

@@ -0,0 +1,239 @@
use notgraph_lib::{analysis, emit, module_graph, symbols, types};
use std::path::Path;
use tempfile::TempDir;
fn fixture_src(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
.join("src")
}
#[test]
fn clean_fixture_has_no_cycles() {
let mg = module_graph::build("clean".to_string(), &fixture_src("clean")).unwrap();
assert!(
analysis::detect_cycles(&mg.nodes, &mg.edges).is_empty(),
"expected no cycles; nodes={:?} edges={:?}",
mg.nodes,
mg.edges
);
}
#[test]
fn detect_cycles_finds_cycles_in_synthetic_graph() {
let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let edges = vec![
("a".to_string(), "b".to_string()),
("b".to_string(), "c".to_string()),
("c".to_string(), "a".to_string()), // creates a->b->c->a cycle
];
let cycles = analysis::detect_cycles(&nodes, &edges);
assert!(
!cycles.is_empty(),
"expected detect_cycles to find the a->b->c->a cycle, got empty"
);
}
/// The cyclic fixture's module graph builds without error and has the expected
/// cross-module containment structure. The cycle detection test using synthetic
/// data covers the algorithm; this test confirms the builder handles mutual
/// `pub mod` declarations without panicking.
#[test]
fn cyclic_fixture_module_graph_builds() {
let mg = module_graph::build("cyclic".to_string(), &fixture_src("cyclic")).unwrap();
assert!(
mg.nodes.contains(&"cyclic".to_string()),
"expected root module node 'cyclic'"
);
// foo and bar are declared at the top level in lib.rs
assert!(
mg.nodes.contains(&"cyclic::foo".to_string()),
"expected cyclic::foo node"
);
assert!(
mg.nodes.contains(&"cyclic::bar".to_string()),
"expected cyclic::bar node"
);
}
/// write_all produces report.html, report.md, and report.json in the output dir.
#[test]
fn write_all_creates_expected_files() {
use types::{CrateGraph, ModuleGraph};
let out = TempDir::new().unwrap();
let crate_graph = CrateGraph {
nodes: vec!["alpha".to_string(), "beta".to_string()],
edges: vec![("beta".to_string(), "alpha".to_string())],
};
let module_graphs: Vec<ModuleGraph> = vec![];
let stats = analysis::analyse(&crate_graph, &module_graphs, 3);
let symbol_tables: Vec<types::SymbolTable> = vec![];
emit::write_all(
out.path(),
&crate_graph,
&module_graphs,
&stats,
&symbol_tables,
)
.unwrap();
assert!(
out.path().join("report.html").exists(),
"report.html missing"
);
assert!(out.path().join("report.md").exists(), "report.md missing");
assert!(
out.path().join("report.json").exists(),
"report.json missing"
);
}
/// Crate names with hyphens must not produce malformed Mermaid output — the
/// generated HTML must contain a sanitized node ID (hyphen replaced with _).
#[test]
fn write_all_sanitizes_hyphenated_crate_names() {
use types::{CrateGraph, ModuleGraph};
let out = TempDir::new().unwrap();
let crate_graph = CrateGraph {
nodes: vec!["my-core".to_string(), "my-app".to_string()],
edges: vec![("my-app".to_string(), "my-core".to_string())],
};
let module_graphs: Vec<ModuleGraph> = vec![];
let stats = analysis::analyse(&crate_graph, &module_graphs, 3);
let symbol_tables: Vec<types::SymbolTable> = vec![];
emit::write_all(
out.path(),
&crate_graph,
&module_graphs,
&stats,
&symbol_tables,
)
.unwrap();
let html = std::fs::read_to_string(out.path().join("report.html")).unwrap();
// Raw hyphenated names must not appear as unquoted node IDs
assert!(
!html.contains("my-core["),
"hyphenated ID 'my-core[' must not appear in Mermaid — use my_core"
);
assert!(
html.contains("my_core["),
"sanitized ID 'my_core[' must appear in Mermaid output"
);
}
/// Heatmap: a single-crate graph (max_fan_in = 0 or 1) must not panic and must
/// produce a style directive in the HTML output.
#[test]
fn write_all_heatmap_does_not_panic_for_single_crate() {
use types::{CrateGraph, ModuleGraph};
let out = TempDir::new().unwrap();
let crate_graph = CrateGraph {
nodes: vec!["solo".to_string()],
edges: vec![],
};
let module_graphs: Vec<ModuleGraph> = vec![];
let stats = analysis::analyse(&crate_graph, &module_graphs, 3);
let symbol_tables: Vec<types::SymbolTable> = vec![];
emit::write_all(
out.path(),
&crate_graph,
&module_graphs,
&stats,
&symbol_tables,
)
.unwrap();
let html = std::fs::read_to_string(out.path().join("report.html")).unwrap();
assert!(
html.contains("style solo fill:"),
"heatmap style directive missing for solo crate"
);
}
/// Per-crate module graphs: write_all renders a <h3> section for each
/// ModuleGraph entry in the HTML output.
#[test]
fn write_all_renders_per_crate_module_graphs() {
use types::CrateGraph;
let out = TempDir::new().unwrap();
let mg = module_graph::build("clean".to_string(), &fixture_src("clean")).unwrap();
let crate_graph = CrateGraph {
nodes: vec!["clean".to_string()],
edges: vec![],
};
let stats = analysis::analyse(&crate_graph, std::slice::from_ref(&mg), 3);
let symbol_tables: Vec<types::SymbolTable> = vec![];
emit::write_all(out.path(), &crate_graph, &[mg], &stats, &symbol_tables).unwrap();
let html = std::fs::read_to_string(out.path().join("report.html")).unwrap();
assert!(
html.contains("<h3>clean</h3>"),
"expected per-crate <h3>clean</h3> section in HTML"
);
assert!(
html.contains("flowchart TD"),
"expected flowchart TD directive for module graph"
);
}
/// Cycle callouts: when the crate graph contains cycles, the HTML must include
/// the cycle-entry element with the cycle nodes.
#[test]
fn write_all_cycle_callout_appears_in_html() {
use types::{CrateGraph, ModuleGraph};
let out = TempDir::new().unwrap();
// Build a two-node crate graph with a synthetic cycle
let crate_graph = CrateGraph {
nodes: vec!["alpha".to_string(), "beta".to_string()],
edges: vec![
("alpha".to_string(), "beta".to_string()),
("beta".to_string(), "alpha".to_string()),
],
};
let module_graphs: Vec<ModuleGraph> = vec![];
let stats = analysis::analyse(&crate_graph, &module_graphs, 3);
let symbol_tables: Vec<types::SymbolTable> = vec![];
emit::write_all(
out.path(),
&crate_graph,
&module_graphs,
&stats,
&symbol_tables,
)
.unwrap();
let html = std::fs::read_to_string(out.path().join("report.html")).unwrap();
assert!(
html.contains("cycle-entry"),
"expected cycle-entry element in HTML when cycles are present"
);
}
#[test]
fn symbols_fixture_has_six_public_symbols() {
let table = symbols::build("symbols".to_string(), &fixture_src("symbols")).unwrap();
let pub_count = table.symbols.iter().filter(|s| s.is_pub).count();
assert_eq!(
pub_count,
6,
"expected 6 public symbols (Foo, Bar, Baz, qux, Alias, VALUE), got: {:?}",
table
.symbols
.iter()
.filter(|s| s.is_pub)
.map(|s| &s.name)
.collect::<Vec<_>>()
);
}

View File

@@ -13,11 +13,11 @@ name = "nothooks"
path = "src/lib.rs"
[dependencies]
notcore = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true }
notcore = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
clap = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

121
crates/nothooks/README.md Normal file
View File

@@ -0,0 +1,121 @@
# nothooks
Nushell hook runner for the notfiles workspace. Executes `.nu` scripts in two
distinct lifecycle phases — **dot** (always) and **setup** (once) — with
state persistence so setup hooks are not re-run across invocations.
## Concepts
### HookPhase
Each hook belongs to exactly one phase:
| Phase | When it runs | Typical use |
|-------|-------------|-------------|
| `dot` | Every time nothooks is invoked | Apply shell config, set env vars, refresh symlinks |
| `setup` | Once per machine (tracked in state) | Install packages, create dirs, first-time configuration |
### HookSpec
A hook is described by three fields (defined in `notcore`):
```toml
[[hooks]]
name = "install-starship"
script = "hooks/setup/install-starship.nu"
phase = "setup"
[[hooks]]
name = "set-default-shell"
script = "hooks/dot/shell.nu"
phase = "dot"
```
### State tracking
Setup hooks are tracked in `.nothooks-state.toml` in the state directory
(typically the dotfiles root). Once a setup hook completes successfully its
name is recorded and it will be **skipped** on all future runs.
```toml
# .nothooks-state.toml (auto-generated, do not edit manually)
completed_setup_hooks = ["install-starship", "configure-git"]
```
Dot hooks are **never** recorded in state — they run unconditionally on every
invocation.
### --force re-run
To re-run setup hooks (e.g. after a machine rebuild or to apply changes),
construct the runner with `HookRunner::with_force`. This bypasses the state
check so all setup hooks execute regardless of prior completion.
The notstrap CLI exposes this as `--force`.
## Hook script lifecycle
```
notstrap / nothooks invoked
├── dot phase
│ └── for each hook where phase == "dot"
│ └── nu <script> # always runs
└── setup phase
└── for each hook where phase == "setup"
├── state.is_done(name)?
│ ├── yes (and !force) → skip
│ └── no (or force) → nu <script>
│ └── success → state.mark_done(name)
└── report collected
```
## Writing a hook script
Hook scripts are plain Nushell (`.nu`) files. They receive no arguments and
communicate success/failure via exit code (0 = success, non-zero = failure).
```nushell
#!/usr/bin/env nu
# hooks/setup/install-starship.nu
# Installs starship prompt if not already present.
if (which starship | is-empty) {
print "Installing starship..."
sh -c "curl -sS https://starship.rs/install.sh | sh -s -- -y"
} else {
print "starship already installed, skipping"
}
```
```nushell
#!/usr/bin/env nu
# hooks/dot/xdg-dirs.nu
# Ensure XDG base directories exist on every login.
mkdir ~/.config ~/.cache ~/.local/share ~/.local/state
```
## Usage from Rust
```rust
use nothooks::{HookRunner, run_phase};
use notcore::{HookPhase, HookSpec};
use std::path::PathBuf;
let hooks: Vec<HookSpec> = /* load from notfiles.toml */;
let state_dir = PathBuf::from("/path/to/dotfiles");
// Normal run
let runner = HookRunner::new(state_dir.clone());
// Force re-run of setup hooks
let runner = HookRunner::with_force(state_dir.clone());
let dot_report = run_phase(&hooks, &HookPhase::Dot, &runner);
let setup_report = run_phase(&hooks, &HookPhase::Setup, &runner);
```
`run_phase` returns a `notcore::Report` containing a `StepStatus` (`Ok`,
`Skipped`, or `Failed(msg)`) for each hook in that phase.

View File

@@ -1,8 +1,8 @@
pub mod runner;
pub mod state;
use notcore::{HookPhase, HookSpec, Report};
pub use runner::HookRunner;
use notcore::{HookPhase, HookSpec, Report, StepStatus};
#[derive(Debug, PartialEq)]
pub enum HookResult {
@@ -12,20 +12,12 @@ pub enum HookResult {
}
/// Run all hooks matching `phase` and collect into a `Report`.
///
/// State is loaded once and saved once per call — not once per hook.
pub fn run_phase(
hooks: &[HookSpec],
phase: &HookPhase,
runner: &HookRunner,
) -> Report {
let mut report = Report::default();
for hook in hooks.iter().filter(|h| &h.phase == phase) {
let result = runner.run_hook(hook);
let status = match &result {
HookResult::Ok => StepStatus::Ok,
HookResult::Skipped => StepStatus::Skipped,
HookResult::Failed(msg) => StepStatus::Failed(msg.clone()),
};
report.add(&hook.name, status);
}
report
) -> anyhow::Result<Report> {
runner.run_phase(hooks, phase)
}

View File

@@ -1,7 +1,7 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use nothooks::{run_phase, HookRunner};
use notcore::{HookPhase, HookSpec};
use nothooks::{HookRunner, run_phase};
use std::path::PathBuf;
#[derive(Parser)]
@@ -62,7 +62,7 @@ fn main() -> Result<()> {
HookRunner::new(state_dir)
};
let report = run_phase(&file.hooks, &phase, &runner);
let report = run_phase(&file.hooks, &phase, &runner)?;
report.print();
if report.has_failures() {

View File

@@ -1,8 +1,31 @@
use std::path::PathBuf;
use std::process::Command;
use notcore::{HookPhase, HookSpec};
use crate::HookResult;
use crate::state::HookState;
use anyhow::{Context, Result};
use notcore::{HookPhase, HookSpec, Report, StepStatus};
use std::path::{Path, PathBuf};
use std::process::Command;
fn resolve_interpreter(spec: &HookSpec) -> Result<String, String> {
if let Some(interp) = &spec.interpreter {
return Ok(interp.clone());
}
let ext = std::path::Path::new(&spec.script)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
match ext {
"nu" => Ok("nu".into()),
"sh" => Ok("sh".into()),
"bash" => Ok("bash".into()),
"zsh" => Ok("zsh".into()),
"py" => Ok("python3".into()),
"rb" => Ok("ruby".into()),
_ => Err(format!(
"cannot infer interpreter: no interpreter set and unknown extension {:?}",
ext
)),
}
}
pub struct HookRunner {
state_dir: PathBuf,
@@ -11,29 +34,77 @@ pub struct HookRunner {
impl HookRunner {
pub fn new(state_dir: PathBuf) -> Self {
Self { state_dir, force: false }
Self {
state_dir,
force: false,
}
}
pub fn with_force(state_dir: PathBuf) -> Self {
Self { state_dir, force: true }
Self {
state_dir,
force: true,
}
}
pub fn run_hook(&self, spec: &HookSpec) -> HookResult {
let mut state = HookState::load(&self.state_dir).unwrap_or_default();
/// Run all hooks in `hooks` that match `phase`.
///
/// State is loaded once before iterating and saved once at the end (only if
/// at least one Setup hook completed successfully), avoiding N file reads
/// and writes per phase.
pub fn run_phase(&self, hooks: &[HookSpec], phase: &HookPhase) -> Result<Report> {
let mut state = HookState::load(&self.state_dir)
.with_context(|| format!("loading hook state from {}", self.state_dir.display()))?;
let mut state_dirty = false;
let mut report = Report::default();
for spec in hooks.iter().filter(|h| &h.phase == phase) {
let result = self.run_hook_with_state(spec, &mut state, &mut state_dirty);
let status = match &result {
HookResult::Ok => StepStatus::Ok,
HookResult::Skipped => StepStatus::Skipped,
HookResult::Failed(msg) => StepStatus::Failed(msg.clone()),
};
report.add(&spec.name, status);
}
if state_dirty {
state
.save(&self.state_dir)
.with_context(|| format!("saving hook state into {}", self.state_dir.display()))?;
}
Ok(report)
}
/// Execute a single hook, using the caller-owned `state` and `dirty` flag
/// instead of loading/saving per invocation.
fn run_hook_with_state(
&self,
spec: &HookSpec,
state: &mut HookState,
state_dirty: &mut bool,
) -> HookResult {
if spec.phase == HookPhase::Setup && !self.force && state.is_done(&spec.name) {
return HookResult::Skipped;
}
let result = Command::new("nu")
.arg(&spec.script)
.status();
let interp = match resolve_interpreter(spec) {
Ok(i) => i,
Err(msg) => return HookResult::Failed(msg),
};
let script = self.resolve_script_path(&spec.script);
// SAFETY: `interp` is derived from the file extension or an explicit `interpreter` field
// in HookSpec (both come from config, not untrusted shell input). `spec.script` is a
// filesystem path from config. Both are passed as discrete args with no shell involved,
// so argument injection is not possible.
let result = Command::new(&interp).arg(&script).status();
match result {
Ok(status) if status.success() => {
if spec.phase == HookPhase::Setup {
state.mark_done(&spec.name);
let _ = state.save(&self.state_dir);
*state_dirty = true;
}
HookResult::Ok
}
@@ -41,4 +112,30 @@ impl HookRunner {
Err(e) => HookResult::Failed(e.to_string()),
}
}
/// Run a single hook with its own load/save cycle.
///
/// Prefer [`HookRunner::run_phase`] when running multiple hooks to avoid
/// repeated state I/O.
pub fn run_hook(&self, spec: &HookSpec) -> Result<HookResult> {
let mut state = HookState::load(&self.state_dir)
.with_context(|| format!("loading hook state from {}", self.state_dir.display()))?;
let mut state_dirty = false;
let result = self.run_hook_with_state(spec, &mut state, &mut state_dirty);
if state_dirty {
state
.save(&self.state_dir)
.with_context(|| format!("saving hook state into {}", self.state_dir.display()))?;
}
Ok(result)
}
fn resolve_script_path(&self, script: &str) -> PathBuf {
let script = Path::new(script);
if script.is_absolute() {
script.to_path_buf()
} else {
self.state_dir.join(script)
}
}
}

View File

@@ -1,7 +1,7 @@
use std::collections::HashSet;
use std::path::Path;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
const STATE_FILE: &str = ".nothooks-state.toml";
@@ -22,7 +22,9 @@ impl HookState {
pub fn save(&self, dir: &Path) -> Result<()> {
let path = dir.join(STATE_FILE);
std::fs::write(path, toml::to_string(self)?)?;
let tmp = dir.join(format!("{STATE_FILE}.tmp"));
std::fs::write(&tmp, toml::to_string(self)?)?;
std::fs::rename(&tmp, &path)?;
Ok(())
}

View File

@@ -1,61 +1,82 @@
use notcore::{HookPhase, HookSpec};
use nothooks::{HookResult, HookRunner};
use std::fs;
use tempfile::TempDir;
use nothooks::{HookResult, HookRunner};
use notcore::{HookPhase, HookSpec};
fn make_hook_script(dir: &TempDir, name: &str, content: &str) -> HookSpec {
let path = dir.path().join(format!("{name}.nu"));
let path = dir.path().join(format!("{name}.sh"));
fs::write(&path, content).unwrap();
HookSpec {
name: name.to_string(),
script: path.to_str().unwrap().to_string(),
phase: HookPhase::Dot,
interpreter: None,
}
}
#[test]
fn test_hook_success() {
let dir = TempDir::new().unwrap();
let spec = make_hook_script(&dir, "ok-hook", "print hello");
let spec = make_hook_script(&dir, "ok-hook", "echo hello");
let runner = HookRunner::new(dir.path().to_path_buf());
let result = runner.run_hook(&spec);
let result = runner.run_hook(&spec).unwrap();
assert!(matches!(result, HookResult::Ok));
}
#[test]
fn test_hook_failure() {
let dir = TempDir::new().unwrap();
let spec = make_hook_script(&dir, "fail-hook", "exit 1\n");
let spec = make_hook_script(&dir, "fail-hook", "exit 1");
let runner = HookRunner::new(dir.path().to_path_buf());
let result = runner.run_hook(&spec);
let result = runner.run_hook(&spec).unwrap();
assert!(matches!(result, HookResult::Failed(_)));
}
#[test]
fn test_setup_hook_skipped_on_rerun() {
let dir = TempDir::new().unwrap();
let mut spec = make_hook_script(&dir, "setup-hook", "print ran");
let mut spec = make_hook_script(&dir, "setup-hook", "echo ran");
spec.phase = notcore::HookPhase::Setup;
let runner = HookRunner::new(dir.path().to_path_buf());
let r1 = runner.run_hook(&spec);
let r1 = runner.run_hook(&spec).unwrap();
assert!(matches!(r1, HookResult::Ok));
// Second run — should be skipped
let r2 = runner.run_hook(&spec);
let r2 = runner.run_hook(&spec).unwrap();
assert!(matches!(r2, HookResult::Skipped));
}
#[test]
fn test_setup_hook_force_reruns() {
let dir = TempDir::new().unwrap();
let mut spec = make_hook_script(&dir, "force-hook", "print ran");
let mut spec = make_hook_script(&dir, "force-hook", "echo ran");
spec.phase = notcore::HookPhase::Setup;
let runner = HookRunner::new(dir.path().to_path_buf());
runner.run_hook(&spec);
runner.run_hook(&spec).unwrap();
let runner2 = HookRunner::with_force(dir.path().to_path_buf());
let r2 = runner2.run_hook(&spec);
let r2 = runner2.run_hook(&spec).unwrap();
assert!(matches!(r2, HookResult::Ok));
}
#[test]
fn test_relative_script_path_resolves_from_state_dir() {
let dir = TempDir::new().unwrap();
let scripts_dir = dir.path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
let script = scripts_dir.join("relative.sh");
fs::write(&script, "echo relative-ok").unwrap();
let spec = HookSpec {
name: "relative".to_string(),
script: "scripts/relative.sh".to_string(),
phase: HookPhase::Dot,
interpreter: Some("sh".to_string()),
};
let runner = HookRunner::new(dir.path().to_path_buf());
let result = runner.run_hook(&spec).unwrap();
assert!(matches!(result, HookResult::Ok));
}

Some files were not shown because too many files have changed in this diff Show More