Merge pull request #20 from 89jobrien/chore/sort-cargo-manifests
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
Some checks failed
Public Repo Readiness / Check Public Repo Readiness (push) Has been cancelled
chore(manifests): sort Cargo.toml dependencies
This commit is contained in:
25
.claude/settings.json
Normal file
25
.claude/settings.json
Normal 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'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
27
.claude/settings.local.json
Normal file
27
.claude/settings.local.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(doob:*)",
|
||||||
|
"Bash(echo:*)",
|
||||||
|
"Bash(gh:*)",
|
||||||
|
"Bash(op:*)",
|
||||||
|
"Bash(obfsck:*)",
|
||||||
|
"Bash(head:*)",
|
||||||
|
"Bash(git ls-files:*)",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch(domain:www.apache.org)",
|
||||||
|
"Skill(todo:*)",
|
||||||
|
"Bash(PATH=\"/Users/joe/.local/share/mise/shims:$PATH\" which nu)",
|
||||||
|
"Bash(PATH=\"/Users/joe/.local/share/mise/shims:$PATH\" nu --version 2>&1 | head -1)",
|
||||||
|
"Bash(doob todo:*)",
|
||||||
|
"Bash(handoff-detect 2>&1; echo \"exit: $?\")",
|
||||||
|
"Bash(handoff-detect --name && handoff-detect --project)",
|
||||||
|
"Bash(rtk gain:*)",
|
||||||
|
"Bash(rtk rewrite:*)",
|
||||||
|
"Bash(rtk grep:*)",
|
||||||
|
"Bash(nu -c '\nlet result = \\(do { python3 -c \"import json; print\\(json.dumps\\({\\\\\"ok\\\\\": True}\\)\\)\" } | complete\\)\nprint $result.stdout\n')",
|
||||||
|
"Bash(echo '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"grep -n \\\\\"^foo\\\\\" /file.md\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu)",
|
||||||
|
"Bash(echo '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"grep -c pattern file.txt\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu\necho '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"grep -i \\\\\"Hello World\\\\\" /dir/\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu\necho '{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"git status\"}}' | nu /Users/joe/.claude/hooks/nu/pre/grep-to-grep-tool.nu; echo \"exit: $?\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.claude/worktrees/agent-a0b9834e
Submodule
1
.claude/worktrees/agent-a0b9834e
Submodule
Submodule .claude/worktrees/agent-a0b9834e added at 11d329c5a2
1
.claude/worktrees/agent-a27fe5f2
Submodule
1
.claude/worktrees/agent-a27fe5f2
Submodule
Submodule .claude/worktrees/agent-a27fe5f2 added at 0d60583c65
1
.claude/worktrees/agent-a6b81d3b
Submodule
1
.claude/worktrees/agent-a6b81d3b
Submodule
Submodule .claude/worktrees/agent-a6b81d3b added at 11d329c5a2
1
.claude/worktrees/agent-af9e1ed8
Submodule
1
.claude/worktrees/agent-af9e1ed8
Submodule
Submodule .claude/worktrees/agent-af9e1ed8 added at 11d329c5a2
25
.codex/hooks.json
Normal file
25
.codex/hooks.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Edit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash -c 'if [ -f Cargo.toml ]; then cargo check --quiet 2>&1 | head -20; fi'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "nu /Users/joe/dev/notfiles/scripts/preflight.nu"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
.ctx/HANDOFF.md
Normal file
16
.ctx/HANDOFF.md
Normal 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
|
||||||
10
.ctx/HANDOFF.notfiles.notfiles.state.yaml
Normal file
10
.ctx/HANDOFF.notfiles.notfiles.state.yaml
Normal 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
|
||||||
39
.ctx/HANDOFF.notfiles.notfiles.yaml
Normal file
39
.ctx/HANDOFF.notfiles.notfiles.yaml
Normal 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: []
|
||||||
@@ -108,9 +108,9 @@ jobs:
|
|||||||
- name: Public readiness summary
|
- name: Public readiness summary
|
||||||
if: success()
|
if: success()
|
||||||
run: |
|
run: |
|
||||||
echo "✅ No secrets or PII detected"
|
echo "[OK] No secrets or PII detected"
|
||||||
echo "✅ No hardcoded private addresses"
|
echo "[OK] No hardcoded private addresses"
|
||||||
echo "✅ Licenses are OSS-compatible"
|
echo "[OK] Licenses are OSS-compatible"
|
||||||
echo "✅ No secrets files tracked in git"
|
echo "[OK] No secrets files tracked in git"
|
||||||
echo ""
|
echo ""
|
||||||
echo "This repository appears safe to mirror to a public GitHub repo."
|
echo "This repository appears safe to mirror to a public GitHub repo."
|
||||||
|
|||||||
70
.github/workflows/ci.yml
vendored
Normal file
70
.github/workflows/ci.yml
vendored
Normal 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
28
.github/workflows/nightly.yml
vendored
Normal 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
198
.github/workflows/release.yml
vendored
Normal 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
22
.gitignore
vendored
@@ -1,4 +1,26 @@
|
|||||||
/target
|
/target
|
||||||
|
.DS_Store
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
.envrc
|
.envrc
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.claude.local.md
|
.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
76
.notfiles-state.toml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/aliases.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/aliases.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728685+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/audit.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/audit.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728734+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/did-you-mean.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/did-you-mean.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728773+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/functions.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/functions.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728809+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/nu_libs.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/nu_libs.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728847+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/nuenv.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/nuenv.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728879+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/session-guard.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/session-guard.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728915+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/settings.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/settings.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728947+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/autoload/toolkit-hook.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/autoload/toolkit-hook.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.728981+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/config.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/config.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.729023+00:00"
|
||||||
|
|
||||||
|
[[entries]]
|
||||||
|
package = "nushell"
|
||||||
|
source = "/Users/joe/dev/notfiles/nushell/.config/nushell/env.nu"
|
||||||
|
target = "/Users/joe/.config/nushell/env.nu"
|
||||||
|
method = "symlink"
|
||||||
|
linked_at = "2026-06-14T04:23:50.729055+00:00"
|
||||||
11
.sccignore
Normal file
11
.sccignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.ctx
|
||||||
|
.kgx
|
||||||
|
.github
|
||||||
|
.claude
|
||||||
|
docs
|
||||||
|
examples
|
||||||
|
fuzz
|
||||||
|
target
|
||||||
|
*.md
|
||||||
|
*.json
|
||||||
|
tests/
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"idle timeout","timestamp":1776399095365}
|
||||||
@@ -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"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
72766
|
||||||
110
AGENTS.md
Normal file
110
AGENTS.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What is notfiles?
|
||||||
|
|
||||||
|
A modern dotfiles manager written in Rust — a pure Rust alternative to GNU Stow. It symlinks (or copies) files from organized "package" directories into a target location (typically `~`).
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build # build all workspace crates
|
||||||
|
cargo build -p notfiles # build just the notfiles binary
|
||||||
|
cargo test # run all tests across workspace
|
||||||
|
cargo test -p notcore # test notcore only
|
||||||
|
cargo test -p notfiles # test notfiles only
|
||||||
|
cargo test -p notsecrets # test notsecrets only
|
||||||
|
cargo test -p nothooks # test nothooks only
|
||||||
|
cargo clippy --workspace # lint all crates
|
||||||
|
cargo fmt --check # check formatting
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workspace Structure
|
||||||
|
|
||||||
|
This is a Cargo workspace with 7 crates under `crates/`:
|
||||||
|
|
||||||
|
| Crate | Purpose |
|
||||||
|
| ------------ | -------------------------------------------------------------------------- |
|
||||||
|
| `notcore` | Shared types: `Config`, `NotfilesError`, `Reporter`, `LinkEvent`, |
|
||||||
|
| | `expand_tilde`, `suggest_package`, `HookPhase`, `HookSpec`, `Report` |
|
||||||
|
| `notfiles` | Dotfiles linker — lib + `notfiles` binary. Subcommands: `init`, `link`, |
|
||||||
|
| | `unlink`, `status`, `check`, `diff`, `adopt`, `completions` |
|
||||||
|
| `notsecrets` | Multi-provider secret resolution (`SecretResolver`), age encryption/ |
|
||||||
|
| | decryption, identity management |
|
||||||
|
| `nothooks` | Nushell hook runner with dot/setup phases and state persistence |
|
||||||
|
| `notnet` | Network utilities — Tailscale integration, YubikeySource |
|
||||||
|
| `notstrap` | New-machine bootstrap orchestrator — ties all crates together |
|
||||||
|
| `notgraph` | Dependency/import graph for Rust files — HTML/MD/JSON/Mermaid output, |
|
||||||
|
| | heatmap, cycle detection |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### notfiles (primary user-facing tool)
|
||||||
|
|
||||||
|
Eight subcommands: `init`, `link`, `unlink`, `status`, `check`, `diff`,
|
||||||
|
`adopt`, `completions`. Global flags: `--dry-run`, `--verbose`, `--json`.
|
||||||
|
CLI parsing in `crates/notfiles/src/cli.rs`; dispatch in `src/main.rs`.
|
||||||
|
|
||||||
|
**Core flow for `link`:** `main` → `config.validate()` →
|
||||||
|
`resolve_packages_filtered` (include/exclude + platform filtering) →
|
||||||
|
`collect_files` (recursive walk with ignore filtering) →
|
||||||
|
`linker::link_package` (create symlinks or copies, record in state,
|
||||||
|
return `LinkResult` with counters).
|
||||||
|
|
||||||
|
**Architecture**: Hexagonal (ports/adapters). `FileStore` trait abstracts
|
||||||
|
filesystem I/O; `Reporter` trait abstracts output. Adapters:
|
||||||
|
`FileStoreImpl` (real fs), `InMemoryFileStore` (testing),
|
||||||
|
`TerminalReporter` (ANSI), `JsonReporter` (NDJSON).
|
||||||
|
|
||||||
|
Key modules in `crates/notfiles/src/`:
|
||||||
|
|
||||||
|
- **linker** — Creates/removes symlinks or copies via `FileStore` port.
|
||||||
|
Manages `State` (`.notfiles-state.toml`). Returns `LinkResult` with
|
||||||
|
linked/copied/skipped/backed_up counts. Also provides `adopt_files`.
|
||||||
|
- **package** — Discovers packages with include/exclude and platform
|
||||||
|
filtering. Recursively collects files via `IgnoreMatcher`. Typo
|
||||||
|
suggestions via `suggest_package` on not-found errors.
|
||||||
|
- **ignore** — Glob-based ignore matching using `globset`.
|
||||||
|
- **status** — Compares expected vs actual state:
|
||||||
|
linked/copied/missing/conflict/orphan. Also provides `diff_package`
|
||||||
|
for copy-method divergence detection.
|
||||||
|
- **adapters/** — `FileStoreImpl`, `InMemoryFileStore`,
|
||||||
|
`TerminalReporter`, `JsonReporter`.
|
||||||
|
- **ports** — `FileStore` trait definition.
|
||||||
|
|
||||||
|
### notsecrets
|
||||||
|
|
||||||
|
Multi-provider secret resolution via `SecretResolver`. Providers include
|
||||||
|
`EnvSource`, `OpSource` (1Password), `BitwardenSource`, `FileSource`,
|
||||||
|
`DotenvxSource`, `SopsSource`, and others. Native age encryption/decryption
|
||||||
|
with x25519, SSH ed25519/RSA, and scrypt identity support. No external
|
||||||
|
`sops` or `age` binaries required.
|
||||||
|
|
||||||
|
### nothooks
|
||||||
|
|
||||||
|
`HookRunner` executes `.nu` scripts via `nu <script>`. Two phases: `HookPhase::Dot` (always runs) and `HookPhase::Setup` (runs once, tracked in `.nothooks-state.toml`). `--force` flag reruns setup hooks.
|
||||||
|
|
||||||
|
### notstrap
|
||||||
|
|
||||||
|
Orchestrates in order: prereqs check → load config → clone dotfiles → age key → decrypt SOPS → link dotfiles → run hooks → final report. Config file: `notstrap.toml`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`notfiles.toml` lives at the dotfiles directory root. Each subdirectory
|
||||||
|
is a "package".
|
||||||
|
|
||||||
|
Global defaults support `include` (allowlist) or `exclude` (blocklist)
|
||||||
|
for package filtering — mutually exclusive. Per-package config can
|
||||||
|
override: `method` (symlink/copy), `target` directory, additional
|
||||||
|
`ignore` patterns, and `platforms` (e.g. `["linux"]`, `["macos"]`) to
|
||||||
|
gate packages to specific OSes.
|
||||||
|
|
||||||
|
## Edition
|
||||||
|
|
||||||
|
Rust edition 2024. All hook scripts are Nushell (`.nu`) — no `.sh` scripts.
|
||||||
|
|
||||||
|
## CI / Gitea Actions
|
||||||
|
|
||||||
|
Workflows live in `.gitea/workflows/` — mirrors `.github/workflows/` for GitHub.
|
||||||
|
The `public-ready.yml` workflow checks secrets, private IPs, licenses, and tracked secrets files on every push to main.
|
||||||
69
CLAUDE.md
69
CLAUDE.md
@@ -22,34 +22,64 @@ cargo fmt --check # check formatting
|
|||||||
|
|
||||||
## Workspace Structure
|
## Workspace Structure
|
||||||
|
|
||||||
This is a Cargo workspace with 5 crates under `crates/`:
|
This is a Cargo workspace with 7 crates under `crates/`:
|
||||||
|
|
||||||
| Crate | Purpose |
|
| Crate | Purpose |
|
||||||
|-------|---------|
|
| ------------ | -------------------------------------------------------------------------- |
|
||||||
| `notcore` | Shared types: `Config`, `NotfilesError`, `expand_tilde`, `HookPhase`, `HookSpec`, `Report`, `StepStatus` |
|
| `notcore` | Shared types: `Config`, `NotfilesError`, `Reporter`, `LinkEvent`, |
|
||||||
| `notfiles` | Dotfiles linker — lib + `notfiles` binary (symlink/copy, init, status) |
|
| | `expand_tilde`, `suggest_package`, `HookPhase`, `HookSpec`, `Report` |
|
||||||
| `notsecrets` | Age key retrieval via Bitwarden, file, or prompt; SOPS decryption |
|
| `notfiles` | Dotfiles linker — lib + `notfiles` binary. Subcommands: `init`, `link`, |
|
||||||
| `nothooks` | Nushell hook runner with dot/setup phases and state persistence |
|
| | `unlink`, `status`, `check`, `diff`, `adopt`, `completions` |
|
||||||
| `notstrap` | New-machine bootstrap orchestrator — ties all crates together |
|
| `notsecrets` | Multi-provider secret resolution (`SecretResolver`), age encryption/ |
|
||||||
|
| | decryption, identity management |
|
||||||
|
| `nothooks` | Nushell hook runner with dot/setup phases and state persistence |
|
||||||
|
| `notnet` | Network utilities — Tailscale integration, YubikeySource |
|
||||||
|
| `notstrap` | New-machine bootstrap orchestrator — ties all crates together |
|
||||||
|
| `notgraph` | Dependency/import graph for Rust files — HTML/MD/JSON/Mermaid output, |
|
||||||
|
| | heatmap, cycle detection |
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### notfiles (primary user-facing tool)
|
### notfiles (primary user-facing tool)
|
||||||
|
|
||||||
Four subcommands: `init`, `link`, `unlink`, `status`. CLI parsing in `crates/notfiles/src/cli.rs`; dispatch in `src/main.rs`.
|
Eight subcommands: `init`, `link`, `unlink`, `status`, `check`, `diff`,
|
||||||
|
`adopt`, `completions`. Global flags: `--dry-run`, `--verbose`, `--json`.
|
||||||
|
CLI parsing in `crates/notfiles/src/cli.rs`; dispatch in `src/main.rs`.
|
||||||
|
|
||||||
**Core flow for `link`:** `main` → `resolve_packages` → `collect_files` (recursive walk with ignore filtering) → `linker::link_package` (create symlinks or copies, record in state).
|
**Core flow for `link`:** `main` → `config.validate()` →
|
||||||
|
`resolve_packages_filtered` (include/exclude + platform filtering) →
|
||||||
|
`collect_files` (recursive walk with ignore filtering) →
|
||||||
|
`linker::link_package` (create symlinks or copies, record in state,
|
||||||
|
return `LinkResult` with counters).
|
||||||
|
|
||||||
|
**Architecture**: Hexagonal (ports/adapters). `FileStore` trait abstracts
|
||||||
|
filesystem I/O; `Reporter` trait abstracts output. Adapters:
|
||||||
|
`FileStoreImpl` (real fs), `InMemoryFileStore` (testing),
|
||||||
|
`TerminalReporter` (ANSI), `JsonReporter` (NDJSON).
|
||||||
|
|
||||||
Key modules in `crates/notfiles/src/`:
|
Key modules in `crates/notfiles/src/`:
|
||||||
- **linker** — Creates/removes symlinks or copies. Manages `State` (`.notfiles-state.toml`) tracking every linked file. Handles conflict detection, `--force` backups, empty-parent cleanup on unlink.
|
|
||||||
- **config** — Parses `notfiles.toml`. Per-package overrides for method, target, ignore. Falls back to `[defaults]`.
|
- **linker** — Creates/removes symlinks or copies via `FileStore` port.
|
||||||
- **package** — Discovers packages (non-hidden subdirs) and recursively collects files via `IgnoreMatcher`.
|
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`.
|
- **ignore** — Glob-based ignore matching using `globset`.
|
||||||
- **status** — Compares expected vs actual state: linked/copied/missing/conflict/orphan.
|
- **status** — Compares expected vs actual state:
|
||||||
|
linked/copied/missing/conflict/orphan. Also provides `diff_package`
|
||||||
|
for copy-method divergence detection.
|
||||||
|
- **adapters/** — `FileStoreImpl`, `InMemoryFileStore`,
|
||||||
|
`TerminalReporter`, `JsonReporter`.
|
||||||
|
- **ports** — `FileStore` trait definition.
|
||||||
|
|
||||||
### notsecrets
|
### notsecrets
|
||||||
|
|
||||||
`AgeKeySource` trait with three implementations: `BitwardenSource` (bw CLI), `FileSource`, `PromptSource`. `resolve_age_key()` tries each in order. `install_age_key()` writes to `~/.config/sops/age/keys.txt` (mode 0600). `decrypt_sops()` shells out to `sops --decrypt`.
|
Multi-provider secret resolution via `SecretResolver`. Providers include
|
||||||
|
`EnvSource`, `OpSource` (1Password), `BitwardenSource`, `FileSource`,
|
||||||
|
`DotenvxSource`, `SopsSource`, and others. Native age encryption/decryption
|
||||||
|
with x25519, SSH ed25519/RSA, and scrypt identity support. No external
|
||||||
|
`sops` or `age` binaries required.
|
||||||
|
|
||||||
### nothooks
|
### nothooks
|
||||||
|
|
||||||
@@ -61,7 +91,14 @@ Orchestrates in order: prereqs check → load config → clone dotfiles → age
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
`notfiles.toml` lives at the dotfiles directory root. Each subdirectory is a "package". Per-package config can override: `method` (symlink/copy), `target` directory, additional `ignore` patterns.
|
`notfiles.toml` lives at the dotfiles directory root. Each subdirectory
|
||||||
|
is a "package".
|
||||||
|
|
||||||
|
Global defaults support `include` (allowlist) or `exclude` (blocklist)
|
||||||
|
for package filtering — mutually exclusive. Per-package config can
|
||||||
|
override: `method` (symlink/copy), `target` directory, additional
|
||||||
|
`ignore` patterns, and `platforms` (e.g. `["linux"]`, `["macos"]`) to
|
||||||
|
gate packages to specific OSes.
|
||||||
|
|
||||||
## Edition
|
## Edition
|
||||||
|
|
||||||
|
|||||||
1416
Cargo.lock
generated
1416
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
@@ -1,5 +1,10 @@
|
|||||||
[workspace.package]
|
[workspace.package]
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/89jobrien/notfiles"
|
||||||
|
homepage = "https://github.com/89jobrien/notfiles"
|
||||||
|
keywords = ["dotfiles", "symlink", "stow", "config"]
|
||||||
|
categories = ["command-line-utilities", "filesystem"]
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
@@ -7,20 +12,29 @@ members = [
|
|||||||
"crates/notfiles",
|
"crates/notfiles",
|
||||||
"crates/notsecrets",
|
"crates/notsecrets",
|
||||||
"crates/nothooks",
|
"crates/nothooks",
|
||||||
|
"crates/notnet",
|
||||||
"crates/notstrap",
|
"crates/notstrap",
|
||||||
|
"crates/notgraph",
|
||||||
|
"crates/notforge",
|
||||||
"tests/integration",
|
"tests/integration",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
base64 = "0.22"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
clap_complete = "4"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
globset = "0.4"
|
globset = "0.4"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
strsim = "0.11"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
|
miette = { version = "7", features = ["derive"] }
|
||||||
|
ureq = { version = "2", features = ["json"] }
|
||||||
which = "7"
|
which = "7"
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
@@ -29,6 +43,8 @@ notcore = { path = "crates/notcore" }
|
|||||||
notfiles = { path = "crates/notfiles" }
|
notfiles = { path = "crates/notfiles" }
|
||||||
notsecrets = { path = "crates/notsecrets" }
|
notsecrets = { path = "crates/notsecrets" }
|
||||||
nothooks = { path = "crates/nothooks" }
|
nothooks = { path = "crates/nothooks" }
|
||||||
|
notnet = { path = "crates/notnet" }
|
||||||
|
notforge = { path = "crates/notforge" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
43
HANDOFF.md
43
HANDOFF.md
@@ -1,37 +1,24 @@
|
|||||||
# HANDOFF.md
|
# 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:
|
Active workflow:
|
||||||
- `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
|
|
||||||
|
|
||||||
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
|
- Handoff context should be brief and ephemeral
|
||||||
```bash
|
- Only open work should appear in handoff files
|
||||||
git push
|
- Closed issues should be removed from handoff files after sync
|
||||||
```
|
- Historical status belongs in GitHub issues and git history, not here
|
||||||
8 commits ahead of `gitea/main`. Nothing blocking this.
|
|
||||||
|
|
||||||
### 2. notstrap has an empty lib.rs
|
Current state:
|
||||||
`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.
|
|
||||||
|
|
||||||
### 3. notstrap config not documented
|
- No open handoff items are tracked locally
|
||||||
`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.
|
- Use GitHub issues plus `doob` as the task source of truth
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|||||||
27
Justfile
Normal file
27
Justfile
Normal 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
|
||||||
64
README.md
64
README.md
@@ -15,10 +15,24 @@ notfiles link
|
|||||||
# Check status
|
# Check status
|
||||||
notfiles status
|
notfiles status
|
||||||
|
|
||||||
|
# Validate config without changes (CI-friendly)
|
||||||
|
notfiles check
|
||||||
|
|
||||||
|
# Show copy-method divergence
|
||||||
|
notfiles diff
|
||||||
|
|
||||||
|
# Move an existing file into a package
|
||||||
|
notfiles adopt git .gitconfig
|
||||||
|
|
||||||
# Remove symlinks
|
# Remove symlinks
|
||||||
notfiles unlink
|
notfiles unlink
|
||||||
|
|
||||||
|
# Generate shell completions
|
||||||
|
notfiles completions bash > ~/.bash_completion.d/notfiles
|
||||||
```
|
```
|
||||||
|
|
||||||
|
All commands support `--dry-run`, `--verbose`, and `--json` flags.
|
||||||
|
|
||||||
On a new machine (once `notstrap` ships):
|
On a new machine (once `notstrap` ships):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -33,11 +47,13 @@ notstrap run
|
|||||||
```
|
```
|
||||||
notfiles/
|
notfiles/
|
||||||
├── crates/
|
├── crates/
|
||||||
│ ├── notcore/ # shared types, config, paths, errors
|
│ ├── notcore/ # shared types, config, paths, errors, Reporter trait
|
||||||
│ ├── notfiles/ # symlink engine (stow replacement) ← you are here
|
│ ├── notfiles/ # symlink engine (stow replacement) ← you are here
|
||||||
│ ├── notsecrets/ # age key retrieval + sops decrypt
|
│ ├── notsecrets/ # multi-provider secret resolution + age crypto
|
||||||
│ ├── nothooks/ # hook execution engine
|
│ ├── nothooks/ # hook execution engine
|
||||||
│ └── notstrap/ # new-machine bootstrap orchestrator
|
│ ├── notnet/ # network utilities (Tailscale, Yubikey)
|
||||||
|
│ ├── notstrap/ # new-machine bootstrap orchestrator
|
||||||
|
│ └── notgraph/ # Rust dependency graph visualizer
|
||||||
├── notfiles.toml # symlink package config
|
├── notfiles.toml # symlink package config
|
||||||
└── notstrap.toml # bootstrap hook/phase config
|
└── notstrap.toml # bootstrap hook/phase config
|
||||||
```
|
```
|
||||||
@@ -46,11 +62,13 @@ notfiles/
|
|||||||
|
|
||||||
| Crate | Type | Does |
|
| Crate | Type | Does |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
| `notcore` | lib | Shared types, config, paths, errors — no deps on other crates |
|
| `notcore` | lib | Shared types, config, paths, errors, Reporter trait — no deps on other crates |
|
||||||
| `notfiles` | lib + bin | Symlink/copy packages into `$HOME`, track state |
|
| `notfiles` | lib + bin | Symlink/copy packages into `$HOME`, track state, adopt/diff/check |
|
||||||
| `notsecrets` | lib | Retrieve age key → sops decrypt → inject secrets |
|
| `notsecrets` | lib | Multi-provider secret resolution, native age encryption/decryption |
|
||||||
| `nothooks` | lib + bin | Run bootstrap hooks in phases, skip already-run setup hooks |
|
| `nothooks` | lib + bin | Run bootstrap hooks in phases, skip already-run setup hooks |
|
||||||
|
| `notnet` | lib | Tailscale integration, Yubikey identity source |
|
||||||
| `notstrap` | bin | Orchestrate everything on a fresh machine |
|
| `notstrap` | bin | Orchestrate everything on a fresh machine |
|
||||||
|
| `notgraph` | lib + bin | Rust file dependency/import graph with HTML/Mermaid output |
|
||||||
|
|
||||||
### Dependency graph
|
### Dependency graph
|
||||||
|
|
||||||
@@ -60,6 +78,7 @@ notstrap
|
|||||||
│ └── notcore
|
│ └── notcore
|
||||||
├── notsecrets
|
├── notsecrets
|
||||||
│ └── notcore
|
│ └── notcore
|
||||||
|
├── notnet
|
||||||
└── nothooks
|
└── nothooks
|
||||||
└── notcore
|
└── notcore
|
||||||
```
|
```
|
||||||
@@ -91,13 +110,16 @@ State is tracked in `.notfiles-state.toml` so `unlink` and `status` know exactly
|
|||||||
```
|
```
|
||||||
notfiles link
|
notfiles link
|
||||||
│
|
│
|
||||||
├─ resolve_packages() discover subdirs or validate requested names
|
├─ config.validate() check include/exclude mutual exclusion
|
||||||
│
|
│
|
||||||
├─ collect_files() recursive walk, apply ignore patterns (globset)
|
├─ resolve_packages_filtered() include/exclude + platform filtering
|
||||||
│
|
│
|
||||||
├─ conflict_check() existing file? symlink to wrong target?
|
├─ collect_files() recursive walk, apply ignore patterns
|
||||||
│
|
│
|
||||||
└─ linker::link_package() create symlinks (or copies), write state
|
├─ conflict_check() existing file? symlink to wrong target?
|
||||||
|
│
|
||||||
|
└─ linker::link_package() create symlinks (or copies), write state,
|
||||||
|
return LinkResult with counters
|
||||||
```
|
```
|
||||||
|
|
||||||
### State file
|
### State file
|
||||||
@@ -156,16 +178,21 @@ Note: 1Password (`op`) is installed as a **hook** in phase `setup` — after sec
|
|||||||
|
|
||||||
## Secrets Bootstrap Detail
|
## Secrets Bootstrap Detail
|
||||||
|
|
||||||
`notsecrets` implements an `AgeKeySource` trait with three sources tried in order:
|
`notsecrets` implements a `SecretResolver` with pluggable provider sources:
|
||||||
|
|
||||||
```
|
```
|
||||||
AgeKeySource
|
SecretResolver
|
||||||
├── BitwardenSource bw unlock → session token → bw get item age-key
|
├── EnvSource read from environment variables
|
||||||
├── FileSource read from --key-file path (USB, etc.)
|
├── OpSource 1Password CLI (op read)
|
||||||
└── PromptSource read from stdin (paste)
|
├── BitwardenSource bw CLI
|
||||||
|
├── FileSource read from file path
|
||||||
|
├── DotenvxSource dotenvx encrypted .env files
|
||||||
|
├── SopsSource SOPS-encrypted files (native age decryption)
|
||||||
|
└── ... (extensible via SecretSource trait)
|
||||||
```
|
```
|
||||||
|
|
||||||
Once the age key is retrieved, it's written to `~/.config/sops/age/keys.txt` and `sops` decrypts `secrets.sops.env`. The decrypted file contains all critical bootstrap credentials (op, bw, github, openai, anthropic, etc.) and is injected into the environment for subsequent hooks.
|
Age encryption/decryption is handled natively (no external `age` or `sops`
|
||||||
|
binaries). Supports x25519, SSH ed25519/RSA, and scrypt identities.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -190,10 +217,15 @@ Once the age key is retrieved, it's written to `~/.config/sops/age/keys.txt` and
|
|||||||
[defaults]
|
[defaults]
|
||||||
method = "symlink"
|
method = "symlink"
|
||||||
target = "~"
|
target = "~"
|
||||||
|
include = ["git", "zsh", "nushell", "starship"] # allowlist (optional)
|
||||||
|
# exclude = ["scratch"] # or blocklist (mutually exclusive)
|
||||||
|
|
||||||
[packages.secrets]
|
[packages.secrets]
|
||||||
method = "copy" # copy instead of symlink for sensitive files
|
method = "copy" # copy instead of symlink for sensitive files
|
||||||
|
|
||||||
|
[packages.nixos]
|
||||||
|
platforms = ["linux"] # only link on Linux
|
||||||
|
|
||||||
[packages.work]
|
[packages.work]
|
||||||
target = "~/work" # different target dir
|
target = "~/work" # different target dir
|
||||||
ignore = ["*.local"]
|
ignore = ["*.local"]
|
||||||
|
|||||||
@@ -3,10 +3,16 @@ name = "notcore"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
keywords.workspace = true
|
||||||
|
categories.workspace = true
|
||||||
|
description = "Shared types and config for the notfiles dotfiles manager"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
|
||||||
toml = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
dirs = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
dirs = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
strsim = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -18,6 +19,14 @@ pub struct Defaults {
|
|||||||
pub target: String,
|
pub target: String,
|
||||||
#[serde(default = "default_ignore")]
|
#[serde(default = "default_ignore")]
|
||||||
pub ignore: Vec<String>,
|
pub ignore: Vec<String>,
|
||||||
|
/// If set, only these package names are discovered.
|
||||||
|
/// Mutually exclusive with `exclude`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub include: Vec<String>,
|
||||||
|
/// If set, these package names are skipped during discovery.
|
||||||
|
/// Mutually exclusive with `include`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub exclude: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Defaults {
|
impl Default for Defaults {
|
||||||
@@ -25,6 +34,8 @@ impl Default for Defaults {
|
|||||||
Self {
|
Self {
|
||||||
target: default_target(),
|
target: default_target(),
|
||||||
ignore: default_ignore(),
|
ignore: default_ignore(),
|
||||||
|
include: Vec::new(),
|
||||||
|
exclude: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +63,10 @@ pub struct PackageConfig {
|
|||||||
pub target: Option<String>,
|
pub target: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ignore: Vec<String>,
|
pub ignore: Vec<String>,
|
||||||
|
/// If non-empty, this package is only linked on these platforms.
|
||||||
|
/// Valid values: "macos", "linux".
|
||||||
|
#[serde(default)]
|
||||||
|
pub platforms: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
@@ -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 {
|
impl Config {
|
||||||
pub fn load(dotfiles_dir: &Path) -> Result<Self, NotfilesError> {
|
pub fn load(dotfiles_dir: &Path) -> Result<Self, NotfilesError> {
|
||||||
let config_path = dotfiles_dir.join("notfiles.toml");
|
let config_path = dotfiles_dir.join("notfiles.toml");
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
return Ok(Config::default());
|
return Ok(Config::default());
|
||||||
}
|
}
|
||||||
let content = std::fs::read_to_string(&config_path)
|
let content = std::fs::read_to_string(&config_path).map_err(|e| {
|
||||||
.map_err(|e| NotfilesError::Config(format!("reading {}: {e}", config_path.display())))?;
|
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 config: Config = toml::from_str(&content).map_err(|e| {
|
||||||
|
NotfilesError::Config(format!("parsing {}: {e}", config_path.display()))
|
||||||
|
})?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +131,50 @@ impl Config {
|
|||||||
.unwrap_or(&self.defaults.target)
|
.unwrap_or(&self.defaults.target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the include list if non-empty, or None (meaning all).
|
||||||
|
pub fn included_packages(&self) -> Option<Vec<&str>> {
|
||||||
|
if self.defaults.include.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let mut v: Vec<&str> = self.defaults.include.iter().map(|s| s.as_str()).collect();
|
||||||
|
v.sort();
|
||||||
|
Some(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if this package name appears in the exclude list.
|
||||||
|
pub fn is_package_excluded(&self, name: &str) -> bool {
|
||||||
|
self.defaults.exclude.iter().any(|e| e == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate config invariants. Returns Err if include and exclude
|
||||||
|
/// are both non-empty.
|
||||||
|
pub fn validate(&self) -> Result<(), NotfilesError> {
|
||||||
|
if !self.defaults.include.is_empty() && !self.defaults.exclude.is_empty() {
|
||||||
|
return Err(NotfilesError::Validation(
|
||||||
|
"include and exclude are mutually exclusive".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the package should be linked on the current OS.
|
||||||
|
/// Empty platforms list means "all platforms".
|
||||||
|
pub fn is_package_for_current_platform(&self, package: &str) -> bool {
|
||||||
|
let platforms = match self.packages.get(package) {
|
||||||
|
Some(pkg) if !pkg.platforms.is_empty() => &pkg.platforms,
|
||||||
|
_ => return true,
|
||||||
|
};
|
||||||
|
let current = if cfg!(target_os = "macos") {
|
||||||
|
"macos"
|
||||||
|
} else if cfg!(target_os = "linux") {
|
||||||
|
"linux"
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
platforms.iter().any(|p| p == current)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ignore_patterns_for(&self, package: &str) -> Vec<&str> {
|
pub fn ignore_patterns_for(&self, package: &str) -> Vec<&str> {
|
||||||
let mut patterns: Vec<&str> = self.defaults.ignore.iter().map(|s| s.as_str()).collect();
|
let mut patterns: Vec<&str> = self.defaults.ignore.iter().map(|s| s.as_str()).collect();
|
||||||
if let Some(pkg) = self.packages.get(package) {
|
if let Some(pkg) = self.packages.get(package) {
|
||||||
@@ -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 {
|
pub fn starter_toml() -> &'static str {
|
||||||
r#"[defaults]
|
r#"[defaults]
|
||||||
target = "~"
|
target = "~"
|
||||||
@@ -127,6 +221,10 @@ ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfil
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn temp_config_path(name: &str) -> std::path::PathBuf {
|
||||||
|
std::env::temp_dir().join(format!("notcore-{name}-{}.toml", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_default_config() {
|
fn test_default_config() {
|
||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
@@ -136,6 +234,92 @@ mod tests {
|
|||||||
assert_eq!(config.target_for("anything"), "~");
|
assert_eq!(config.target_for("anything"), "~");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_include_filter_restricts_packages() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
ignore = [".git"]
|
||||||
|
include = ["git", "zsh", "nushell"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
config.included_packages(),
|
||||||
|
Some(vec!["git", "nushell", "zsh"]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_exclude_filter_blocks_packages() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
ignore = [".git"]
|
||||||
|
exclude = ["scripts", "docs", "tests"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert!(config.is_package_excluded("scripts"));
|
||||||
|
assert!(!config.is_package_excluded("zsh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_include_and_exclude_mutual_exclusion() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
include = ["git"]
|
||||||
|
exclude = ["scripts"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_include_no_exclude_returns_none() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert_eq!(config.included_packages(), None);
|
||||||
|
assert!(!config.is_package_excluded("anything"));
|
||||||
|
assert!(config.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_platform_filter_linux_only() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
|
||||||
|
[packages.nixos]
|
||||||
|
platforms = ["linux"]
|
||||||
|
|
||||||
|
[packages.git]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
// On macOS this should be false, on Linux true
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
assert!(!config.is_package_for_current_platform("nixos"));
|
||||||
|
} else if cfg!(target_os = "linux") {
|
||||||
|
assert!(config.is_package_for_current_platform("nixos"));
|
||||||
|
}
|
||||||
|
// git has no platform filter — always matches
|
||||||
|
assert!(config.is_package_for_current_platform("git"));
|
||||||
|
// unknown package — no config entry, always matches
|
||||||
|
assert!(config.is_package_for_current_platform("zsh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_platform_filter_macos_only() {
|
||||||
|
let toml_str = r#"
|
||||||
|
[packages.vscode]
|
||||||
|
platforms = ["macos"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
assert!(config.is_package_for_current_platform("vscode"));
|
||||||
|
} else {
|
||||||
|
assert!(!config.is_package_for_current_platform("vscode"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_config() {
|
fn test_parse_config() {
|
||||||
let toml_str = r#"
|
let toml_str = r#"
|
||||||
@@ -160,4 +344,40 @@ target = "~/bin"
|
|||||||
assert!(ssh_ignores.contains(&".git"));
|
assert!(ssh_ignores.contains(&".git"));
|
||||||
assert!(ssh_ignores.contains(&"known_hosts"));
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ pub enum NotfilesError {
|
|||||||
#[error("state file error: {0}")]
|
#[error("state file error: {0}")]
|
||||||
State(String),
|
State(String),
|
||||||
|
|
||||||
|
#[error("glob error: {0}")]
|
||||||
|
Glob(String),
|
||||||
|
|
||||||
|
#[error("validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
//! Core types and configuration for the notfiles dotfiles manager.
|
||||||
|
//!
|
||||||
|
//! This crate provides shared types used across the notfiles workspace:
|
||||||
|
//! configuration parsing, error types, path utilities, and the reporter
|
||||||
|
//! trait for output abstraction.
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
|
pub mod reporter;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
pub use config::{Config, Defaults, Method, PackageConfig};
|
pub use config::{Config, Defaults, Method, PackageConfig, suggest_package};
|
||||||
pub use error::NotfilesError;
|
pub use error::NotfilesError;
|
||||||
pub use paths::{dotfiles_dir, expand_tilde};
|
pub use paths::{dotfiles_dir, expand_tilde};
|
||||||
|
pub use reporter::{LinkEvent, Reporter, SilentReporter};
|
||||||
pub use types::{HookPhase, HookSpec, PackageSpec, Report, Step, StepStatus};
|
pub use types::{HookPhase, HookSpec, PackageSpec, Report, Step, StepStatus};
|
||||||
|
|||||||
46
crates/notcore/src/reporter.rs
Normal file
46
crates/notcore/src/reporter.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Events emitted during link/unlink/status operations.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum LinkEvent<'a> {
|
||||||
|
Link {
|
||||||
|
source: &'a str,
|
||||||
|
target: &'a Path,
|
||||||
|
},
|
||||||
|
Copy {
|
||||||
|
source: &'a str,
|
||||||
|
target: &'a Path,
|
||||||
|
},
|
||||||
|
Skip {
|
||||||
|
source: &'a str,
|
||||||
|
reason: &'a str,
|
||||||
|
},
|
||||||
|
Backup {
|
||||||
|
from: &'a Path,
|
||||||
|
to: &'a Path,
|
||||||
|
},
|
||||||
|
Remove {
|
||||||
|
target: &'a Path,
|
||||||
|
},
|
||||||
|
CreateDir {
|
||||||
|
path: &'a Path,
|
||||||
|
},
|
||||||
|
DryRun {
|
||||||
|
action: &'a str,
|
||||||
|
source: &'a str,
|
||||||
|
target: &'a Path,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Port for reporting link/unlink progress. Library code calls this
|
||||||
|
/// instead of println!.
|
||||||
|
pub trait Reporter {
|
||||||
|
fn report(&self, event: &LinkEvent<'_>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No-op reporter for when output is unwanted.
|
||||||
|
pub struct SilentReporter;
|
||||||
|
|
||||||
|
impl Reporter for SilentReporter {
|
||||||
|
fn report(&self, _event: &LinkEvent<'_>) {}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ pub struct HookSpec {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub script: String,
|
pub script: String,
|
||||||
pub phase: HookPhase,
|
pub phase: HookPhase,
|
||||||
|
#[serde(default)]
|
||||||
|
pub interpreter: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -48,7 +50,10 @@ pub struct Report {
|
|||||||
|
|
||||||
impl Report {
|
impl Report {
|
||||||
pub fn add(&mut self, name: impl Into<String>, status: StepStatus) {
|
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) {
|
pub fn print(&self) {
|
||||||
@@ -67,7 +72,9 @@ impl Report {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_failures(&self) -> bool {
|
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(_)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ name = "notfiles"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
keywords.workspace = true
|
||||||
|
categories.workspace = true
|
||||||
description = "A modern dotfiles manager — pure Rust alternative to GNU Stow"
|
description = "A modern dotfiles manager — pure Rust alternative to GNU Stow"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
@@ -14,15 +18,17 @@ name = "notfiles"
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
notcore = { workspace = true }
|
|
||||||
clap = { workspace = true }
|
|
||||||
globset = { workspace = true }
|
|
||||||
chrono = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
toml = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
clap_complete = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
|
globset = { workspace = true }
|
||||||
|
notcore = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
|
||||||
assert_fs = { workspace = true }
|
assert_fs = { workspace = true }
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|||||||
70
crates/notfiles/src/adapters/fs.rs
Normal file
70
crates/notfiles/src/adapters/fs.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
293
crates/notfiles/src/adapters/memory.rs
Normal file
293
crates/notfiles/src/adapters/memory.rs
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::ports::FileStore;
|
||||||
|
|
||||||
|
/// In-memory file system for testing. Not thread-safe.
|
||||||
|
pub struct InMemoryFileStore {
|
||||||
|
files: RefCell<HashMap<PathBuf, Vec<u8>>>,
|
||||||
|
dirs: RefCell<HashSet<PathBuf>>,
|
||||||
|
symlinks: RefCell<HashMap<PathBuf, PathBuf>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryFileStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
files: RefCell::new(HashMap::new()),
|
||||||
|
dirs: RefCell::new(HashSet::new()),
|
||||||
|
symlinks: RefCell::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_file(&self, path: impl AsRef<Path>, content: &[u8]) {
|
||||||
|
let path = path.as_ref().to_path_buf();
|
||||||
|
// Ensure parent dirs exist
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
self.ensure_parents(parent);
|
||||||
|
}
|
||||||
|
self.files.borrow_mut().insert(path, content.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_dir(&self, path: impl AsRef<Path>) {
|
||||||
|
self.ensure_parents(path.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_symlink(&self, link: &Path) -> bool {
|
||||||
|
self.symlinks.borrow().contains_key(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn symlink_target(&self, link: &Path) -> Option<PathBuf> {
|
||||||
|
self.symlinks.borrow().get(link).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_parents(&self, path: &Path) {
|
||||||
|
let mut current = path.to_path_buf();
|
||||||
|
let mut dirs = self.dirs.borrow_mut();
|
||||||
|
loop {
|
||||||
|
if !dirs.insert(current.clone()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match current.parent() {
|
||||||
|
Some(p) if p != current => current = p.to_path_buf(),
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InMemoryFileStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileStore for InMemoryFileStore {
|
||||||
|
fn read(&self, path: &Path) -> Result<Vec<u8>, std::io::Error> {
|
||||||
|
// Follow symlinks
|
||||||
|
if let Some(target) = self.symlinks.borrow().get(path) {
|
||||||
|
return self.read(target);
|
||||||
|
}
|
||||||
|
self.files.borrow().get(path).cloned().ok_or_else(|| {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::NotFound, path.display().to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_to_string(&self, path: &Path) -> Result<String, std::io::Error> {
|
||||||
|
let bytes = self.read(path)?;
|
||||||
|
String::from_utf8(bytes)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, path: &Path, contents: &[u8]) -> Result<(), std::io::Error> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
self.ensure_parents(parent);
|
||||||
|
}
|
||||||
|
self.files
|
||||||
|
.borrow_mut()
|
||||||
|
.insert(path.to_path_buf(), contents.to_vec());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename(&self, from: &Path, to: &Path) -> Result<(), std::io::Error> {
|
||||||
|
let content = self.files.borrow_mut().remove(from);
|
||||||
|
if let Some(content) = content {
|
||||||
|
if let Some(parent) = to.parent() {
|
||||||
|
self.ensure_parents(parent);
|
||||||
|
}
|
||||||
|
self.files.borrow_mut().insert(to.to_path_buf(), content);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if self.symlinks.borrow_mut().remove(from).is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
from.display().to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_file(&self, path: &Path) -> Result<(), std::io::Error> {
|
||||||
|
if self.files.borrow_mut().remove(path).is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if self.symlinks.borrow_mut().remove(path).is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
path.display().to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_dir_all(&self, path: &Path) -> Result<(), std::io::Error> {
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
self.files.borrow_mut().retain(|k, _| !k.starts_with(&path));
|
||||||
|
self.dirs.borrow_mut().retain(|k| !k.starts_with(&path));
|
||||||
|
self.symlinks
|
||||||
|
.borrow_mut()
|
||||||
|
.retain(|k, _| !k.starts_with(&path));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_dir(&self, path: &Path) -> Result<(), std::io::Error> {
|
||||||
|
self.dirs.borrow_mut().remove(path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_link(&self, path: &Path) -> Result<PathBuf, std::io::Error> {
|
||||||
|
self.symlinks.borrow().get(path).cloned().ok_or_else(|| {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::NotFound, path.display().to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn symlink_metadata(&self, path: &Path) -> Result<std::fs::Metadata, std::io::Error> {
|
||||||
|
// We can't construct real Metadata, but we can indicate existence
|
||||||
|
// by returning NotFound when the path doesn't exist
|
||||||
|
if self.files.borrow().contains_key(path)
|
||||||
|
|| self.dirs.borrow().contains(path)
|
||||||
|
|| self.symlinks.borrow().contains_key(path)
|
||||||
|
{
|
||||||
|
// Return metadata of a real temp file as a stand-in
|
||||||
|
// This is a known limitation of the in-memory store
|
||||||
|
Err(std::io::Error::other(
|
||||||
|
"InMemoryFileStore: metadata not available",
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
path.display().to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata(&self, path: &Path) -> Result<std::fs::Metadata, std::io::Error> {
|
||||||
|
self.symlink_metadata(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_dir_all(&self, path: &Path) -> Result<(), std::io::Error> {
|
||||||
|
self.ensure_parents(path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, std::io::Error> {
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
let mut children = Vec::new();
|
||||||
|
|
||||||
|
for key in self.files.borrow().keys() {
|
||||||
|
if key.parent() == Some(&path) {
|
||||||
|
children.push(key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for dir in self.dirs.borrow().iter() {
|
||||||
|
if dir.parent() == Some(&path) && dir != &path && !children.contains(dir) {
|
||||||
|
children.push(dir.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in self.symlinks.borrow().keys() {
|
||||||
|
if key.parent() == Some(&path) && !children.contains(key) {
|
||||||
|
children.push(key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(children)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn symlink(&self, target: &Path, link: &Path) -> Result<(), std::io::Error> {
|
||||||
|
if let Some(parent) = link.parent() {
|
||||||
|
self.ensure_parents(parent);
|
||||||
|
}
|
||||||
|
self.symlinks
|
||||||
|
.borrow_mut()
|
||||||
|
.insert(link.to_path_buf(), target.to_path_buf());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exists(&self, path: &Path) -> bool {
|
||||||
|
self.files.borrow().contains_key(path)
|
||||||
|
|| self.dirs.borrow().contains(path)
|
||||||
|
|| self.symlinks.borrow().contains_key(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_dir(&self, path: &Path) -> bool {
|
||||||
|
self.dirs.borrow().contains(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_file_creates_parents() {
|
||||||
|
let fs = InMemoryFileStore::new();
|
||||||
|
fs.add_file("/a/b/c.txt", b"hello");
|
||||||
|
assert!(fs.is_dir(Path::new("/a")));
|
||||||
|
assert!(fs.is_dir(Path::new("/a/b")));
|
||||||
|
assert!(fs.exists(Path::new("/a/b/c.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_write_roundtrip() {
|
||||||
|
let fs = InMemoryFileStore::new();
|
||||||
|
fs.write(Path::new("/tmp/f.txt"), b"data").unwrap();
|
||||||
|
assert_eq!(fs.read(Path::new("/tmp/f.txt")).unwrap(), b"data");
|
||||||
|
assert_eq!(fs.read_to_string(Path::new("/tmp/f.txt")).unwrap(), "data");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_symlink_roundtrip() {
|
||||||
|
let fs = InMemoryFileStore::new();
|
||||||
|
fs.add_file("/src/config", b"content");
|
||||||
|
fs.symlink(Path::new("/src/config"), Path::new("/home/config"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(fs.has_symlink(Path::new("/home/config")));
|
||||||
|
assert_eq!(
|
||||||
|
fs.read_link(Path::new("/home/config")).unwrap(),
|
||||||
|
PathBuf::from("/src/config")
|
||||||
|
);
|
||||||
|
// Reading through symlink
|
||||||
|
assert_eq!(fs.read(Path::new("/home/config")).unwrap(), b"content");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rename_file() {
|
||||||
|
let fs = InMemoryFileStore::new();
|
||||||
|
fs.add_file("/a.txt", b"hello");
|
||||||
|
fs.rename(Path::new("/a.txt"), Path::new("/b.txt")).unwrap();
|
||||||
|
assert!(!fs.exists(Path::new("/a.txt")));
|
||||||
|
assert_eq!(fs.read(Path::new("/b.txt")).unwrap(), b"hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove_dir_all() {
|
||||||
|
let fs = InMemoryFileStore::new();
|
||||||
|
fs.add_file("/dir/a.txt", b"a");
|
||||||
|
fs.add_file("/dir/sub/b.txt", b"b");
|
||||||
|
fs.remove_dir_all(Path::new("/dir")).unwrap();
|
||||||
|
assert!(!fs.exists(Path::new("/dir/a.txt")));
|
||||||
|
assert!(!fs.exists(Path::new("/dir/sub/b.txt")));
|
||||||
|
assert!(!fs.is_dir(Path::new("/dir")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_dir_lists_children() {
|
||||||
|
let fs = InMemoryFileStore::new();
|
||||||
|
fs.add_file("/dir/a.txt", b"a");
|
||||||
|
fs.add_file("/dir/b.txt", b"b");
|
||||||
|
fs.add_dir("/dir/sub");
|
||||||
|
let mut children = fs.read_dir(Path::new("/dir")).unwrap();
|
||||||
|
children.sort();
|
||||||
|
assert_eq!(
|
||||||
|
children,
|
||||||
|
vec![
|
||||||
|
PathBuf::from("/dir/a.txt"),
|
||||||
|
PathBuf::from("/dir/b.txt"),
|
||||||
|
PathBuf::from("/dir/sub"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
crates/notfiles/src/adapters/mod.rs
Normal file
7
crates/notfiles/src/adapters/mod.rs
Normal 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};
|
||||||
81
crates/notfiles/src/adapters/reporter.rs
Normal file
81
crates/notfiles/src/adapters/reporter.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use notcore::reporter::{LinkEvent, Reporter};
|
||||||
|
|
||||||
|
/// Terminal reporter with ANSI color output.
|
||||||
|
pub struct TerminalReporter;
|
||||||
|
|
||||||
|
/// JSON reporter: one JSON object per line to stdout.
|
||||||
|
pub struct JsonReporter;
|
||||||
|
|
||||||
|
impl Reporter for TerminalReporter {
|
||||||
|
fn report(&self, event: &LinkEvent<'_>) {
|
||||||
|
match event {
|
||||||
|
LinkEvent::Link { source, target } => {
|
||||||
|
println!(" \x1b[32mlink\x1b[0m {source} -> {}", target.display());
|
||||||
|
}
|
||||||
|
LinkEvent::Copy { source, target } => {
|
||||||
|
println!(" \x1b[32mcopy\x1b[0m {source} -> {}", target.display());
|
||||||
|
}
|
||||||
|
LinkEvent::Skip { source, reason } => {
|
||||||
|
println!(" \x1b[90mskip\x1b[0m {source} ({reason})");
|
||||||
|
}
|
||||||
|
LinkEvent::Backup { from, to } => {
|
||||||
|
println!(
|
||||||
|
" \x1b[33mbackup\x1b[0m {} -> {}",
|
||||||
|
from.display(),
|
||||||
|
to.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
LinkEvent::Remove { target } => {
|
||||||
|
println!(" \x1b[31mremove\x1b[0m {}", target.display());
|
||||||
|
}
|
||||||
|
LinkEvent::CreateDir { path } => {
|
||||||
|
println!(" \x1b[90mcreate dir\x1b[0m {}", path.display());
|
||||||
|
}
|
||||||
|
LinkEvent::DryRun {
|
||||||
|
action,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
} => {
|
||||||
|
println!(
|
||||||
|
" \x1b[36mwould {action}\x1b[0m {source} -> {}",
|
||||||
|
target.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reporter for JsonReporter {
|
||||||
|
fn report(&self, event: &LinkEvent<'_>) {
|
||||||
|
let obj = match event {
|
||||||
|
LinkEvent::Link { source, target } => {
|
||||||
|
json!({"event": "link", "source": source, "target": target.to_string_lossy()})
|
||||||
|
}
|
||||||
|
LinkEvent::Copy { source, target } => {
|
||||||
|
json!({"event": "copy", "source": source, "target": target.to_string_lossy()})
|
||||||
|
}
|
||||||
|
LinkEvent::Skip { source, reason } => {
|
||||||
|
json!({"event": "skip", "source": source, "reason": reason})
|
||||||
|
}
|
||||||
|
LinkEvent::Backup { from, to } => {
|
||||||
|
json!({"event": "backup", "from": from.to_string_lossy(), "to": to.to_string_lossy()})
|
||||||
|
}
|
||||||
|
LinkEvent::Remove { target } => {
|
||||||
|
json!({"event": "remove", "target": target.to_string_lossy()})
|
||||||
|
}
|
||||||
|
LinkEvent::CreateDir { path } => {
|
||||||
|
json!({"event": "create_dir", "path": path.to_string_lossy()})
|
||||||
|
}
|
||||||
|
LinkEvent::DryRun {
|
||||||
|
action,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
} => {
|
||||||
|
json!({"event": "dry_run", "action": action, "source": source, "target": target.to_string_lossy()})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,10 +16,39 @@ pub struct Cli {
|
|||||||
#[arg(long, short, global = true)]
|
#[arg(long, short, global = true)]
|
||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
|
|
||||||
|
/// Output in JSON format
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub json: bool,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Command,
|
pub command: Command,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)]
|
#[derive(Subcommand, Debug)]
|
||||||
pub enum Command {
|
pub enum Command {
|
||||||
/// Create a starter notfiles.toml
|
/// Create a starter notfiles.toml
|
||||||
@@ -50,4 +79,31 @@ pub enum Command {
|
|||||||
/// Specific packages to check (default: all)
|
/// Specific packages to check (default: all)
|
||||||
packages: Vec<String>,
|
packages: Vec<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Validate config without making changes (CI-friendly)
|
||||||
|
Check,
|
||||||
|
|
||||||
|
/// Generate shell completions
|
||||||
|
Completions {
|
||||||
|
/// Shell to generate for
|
||||||
|
#[arg(value_enum)]
|
||||||
|
shell: clap_complete::Shell,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show differences between source and target for copy-method packages
|
||||||
|
Diff {
|
||||||
|
/// Specific packages to diff (default: all copy-method packages)
|
||||||
|
packages: Vec<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Auto-detect existing dotfile managers (stow, chezmoi, etc.)
|
||||||
|
Detect,
|
||||||
|
|
||||||
|
/// Move existing files into a package and replace with symlinks
|
||||||
|
Adopt {
|
||||||
|
/// Package to adopt files into
|
||||||
|
package: String,
|
||||||
|
/// File paths (relative to target dir) to adopt
|
||||||
|
files: Vec<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
300
crates/notfiles/src/detect.rs
Normal file
300
crates/notfiles/src/detect.rs
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
//! Auto-detection of existing dotfile managers.
|
||||||
|
//!
|
||||||
|
//! Scans common dotfile repo locations and identifies what's managing them.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// A dotfile manager detected on disk.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DetectedManager {
|
||||||
|
pub kind: ManagerKind,
|
||||||
|
pub root: PathBuf,
|
||||||
|
pub packages: Vec<DetectedPackage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum ManagerKind {
|
||||||
|
/// GNU Stow — packages are top-level dirs, files mirror $HOME layout.
|
||||||
|
Stow,
|
||||||
|
/// chezmoi — uses `.chezmoi.*` config and `dot_` prefix convention.
|
||||||
|
Chezmoi,
|
||||||
|
/// Notfiles — has a `notfiles.toml` config file.
|
||||||
|
Notfiles,
|
||||||
|
/// Unknown structure — looks like a dotfile repo but manager unclear.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ManagerKind {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ManagerKind::Stow => write!(f, "stow"),
|
||||||
|
ManagerKind::Chezmoi => write!(f, "chezmoi"),
|
||||||
|
ManagerKind::Notfiles => write!(f, "notfiles"),
|
||||||
|
ManagerKind::Unknown => write!(f, "unknown"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A package (top-level directory) inside a detected dotfile repo.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DetectedPackage {
|
||||||
|
pub name: String,
|
||||||
|
/// Files relative to the package directory (mirrors home layout).
|
||||||
|
pub files: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locations to probe relative to `$HOME`.
|
||||||
|
const CANDIDATE_DIRS: &[&str] = &[
|
||||||
|
"dotfiles",
|
||||||
|
".dotfiles",
|
||||||
|
"dot",
|
||||||
|
".dot",
|
||||||
|
"dots",
|
||||||
|
".dots",
|
||||||
|
"config/dotfiles",
|
||||||
|
".config/dotfiles",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Detect all dotfile managers reachable from `home`.
|
||||||
|
pub fn detect(home: &Path) -> Vec<DetectedManager> {
|
||||||
|
let mut found = Vec::new();
|
||||||
|
|
||||||
|
for rel in CANDIDATE_DIRS {
|
||||||
|
let dir = home.join(rel);
|
||||||
|
if dir.is_dir()
|
||||||
|
&& let Some(m) = probe(&dir)
|
||||||
|
{
|
||||||
|
found.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chezmoi source dir at its default non-standard location
|
||||||
|
let chezmoi_src = home.join(".local/share/chezmoi");
|
||||||
|
if chezmoi_src.is_dir() && !found.iter().any(|m| m.root == chezmoi_src) {
|
||||||
|
found.push(DetectedManager {
|
||||||
|
kind: ManagerKind::Chezmoi,
|
||||||
|
packages: enumerate_chezmoi_packages(&chezmoi_src),
|
||||||
|
root: chezmoi_src,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe(dir: &Path) -> Option<DetectedManager> {
|
||||||
|
let kind = classify(dir);
|
||||||
|
let packages = match kind {
|
||||||
|
ManagerKind::Stow | ManagerKind::Unknown => enumerate_stow_packages(dir),
|
||||||
|
ManagerKind::Chezmoi => enumerate_chezmoi_packages(dir),
|
||||||
|
ManagerKind::Notfiles => enumerate_notfiles_packages(dir),
|
||||||
|
};
|
||||||
|
Some(DetectedManager {
|
||||||
|
kind,
|
||||||
|
root: dir.to_path_buf(),
|
||||||
|
packages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify(dir: &Path) -> ManagerKind {
|
||||||
|
if dir.join("notfiles.toml").exists() {
|
||||||
|
return ManagerKind::Notfiles;
|
||||||
|
}
|
||||||
|
if dir.join(".chezmoi.toml.tmpl").exists()
|
||||||
|
|| dir.join(".chezmoi.yaml.tmpl").exists()
|
||||||
|
|| dir.join(".chezmoi.json.tmpl").exists()
|
||||||
|
|| dir.join(".chezmoi.toml").exists()
|
||||||
|
|| dir.join(".chezmoi.yaml").exists()
|
||||||
|
{
|
||||||
|
return ManagerKind::Chezmoi;
|
||||||
|
}
|
||||||
|
if dir.join(".stow-local-ignore").exists()
|
||||||
|
|| dir.join(".stowrc").exists()
|
||||||
|
|| has_stow_structure(dir)
|
||||||
|
{
|
||||||
|
return ManagerKind::Stow;
|
||||||
|
}
|
||||||
|
ManagerKind::Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heuristic: looks like stow if ≥2 top-level subdirs contain dot-dirs or
|
||||||
|
/// known config paths (`.config/`, `.local/`, `Library/`, etc.).
|
||||||
|
fn has_stow_structure(dir: &Path) -> bool {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut stow_like = 0usize;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = entry.file_name();
|
||||||
|
let s = name.to_string_lossy();
|
||||||
|
if s.starts_with('.') || matches!(s.as_ref(), "target" | "src") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if package_looks_like_stow(&path) {
|
||||||
|
stow_like += 1;
|
||||||
|
if stow_like >= 2 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn package_looks_like_stow(pkg_dir: &Path) -> bool {
|
||||||
|
let Ok(entries) = std::fs::read_dir(pkg_dir) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let s = name.to_string_lossy();
|
||||||
|
if s.starts_with('.') || s == "Library" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enumerate_stow_packages(root: &Path) -> Vec<DetectedPackage> {
|
||||||
|
let Ok(entries) = std::fs::read_dir(root) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let mut pkgs = Vec::new();
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if name.starts_with('.') || matches!(name.as_str(), "target" | "src" | "docs" | "tests") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let files = walk_files(&path, &path);
|
||||||
|
pkgs.push(DetectedPackage { name, files });
|
||||||
|
}
|
||||||
|
pkgs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
pkgs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enumerate_chezmoi_packages(root: &Path) -> Vec<DetectedPackage> {
|
||||||
|
let files = walk_files(root, root);
|
||||||
|
if files.is_empty() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
vec![DetectedPackage {
|
||||||
|
name: "(source)".into(),
|
||||||
|
files,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enumerate_notfiles_packages(root: &Path) -> Vec<DetectedPackage> {
|
||||||
|
let toml_path = root.join("notfiles.toml");
|
||||||
|
if let Ok(content) = std::fs::read_to_string(&toml_path)
|
||||||
|
&& let Some(names) = parse_include_list(&content)
|
||||||
|
{
|
||||||
|
return names
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| {
|
||||||
|
let pkg_dir = root.join(&name);
|
||||||
|
let files = walk_files(&pkg_dir, &pkg_dir);
|
||||||
|
DetectedPackage { name, files }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
enumerate_stow_packages(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal parse of `include = ["a", "b"]` from TOML.
|
||||||
|
fn parse_include_list(toml: &str) -> Option<Vec<String>> {
|
||||||
|
let line = toml
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.trim_start().starts_with("include"))?;
|
||||||
|
let bracket_start = line.find('[')?;
|
||||||
|
let bracket_end = line.find(']')?;
|
||||||
|
let inner = &line[bracket_start + 1..bracket_end];
|
||||||
|
let names: Vec<String> = inner
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
if names.is_empty() { None } else { Some(names) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively collect file paths relative to `base`.
|
||||||
|
fn walk_files(dir: &Path, base: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if matches!(name.as_str(), ".git" | ".DS_Store") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if path.is_dir() {
|
||||||
|
files.extend(walk_files(&path, base));
|
||||||
|
} else if path.is_file()
|
||||||
|
&& let Ok(rel) = path.strip_prefix(base)
|
||||||
|
{
|
||||||
|
files.push(rel.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Output ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn print_detected(managers: &[DetectedManager]) {
|
||||||
|
if managers.is_empty() {
|
||||||
|
println!("No dotfile managers detected.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
println!("Detected dotfile managers:\n");
|
||||||
|
for m in managers {
|
||||||
|
let pkg_count = m.packages.len();
|
||||||
|
let file_count: usize = m.packages.iter().map(|p| p.files.len()).sum();
|
||||||
|
let pkg_names: Vec<&str> = m.packages.iter().map(|p| p.name.as_str()).collect();
|
||||||
|
println!(" \x1b[1m{}\x1b[0m {}", m.kind, m.root.display());
|
||||||
|
println!(
|
||||||
|
" {} package{}, {} file{}",
|
||||||
|
pkg_count,
|
||||||
|
if pkg_count == 1 { "" } else { "s" },
|
||||||
|
file_count,
|
||||||
|
if file_count == 1 { "" } else { "s" },
|
||||||
|
);
|
||||||
|
if !pkg_names.is_empty() {
|
||||||
|
println!(" packages: {}", pkg_names.join(", "));
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_detected_json(managers: &[DetectedManager]) {
|
||||||
|
let items: Vec<_> = managers
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
json!({
|
||||||
|
"manager": m.kind.to_string(),
|
||||||
|
"root": m.root.to_string_lossy(),
|
||||||
|
"packages": m.packages.iter().map(|p| {
|
||||||
|
json!({
|
||||||
|
"name": p.name,
|
||||||
|
"files": p.files.iter()
|
||||||
|
.map(|f| f.to_string_lossy().to_string())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&items).unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ impl IgnoreMatcher {
|
|||||||
// Match the pattern as a filename component and also as a path suffix.
|
// Match the pattern as a filename component and also as a path suffix.
|
||||||
let glob = Glob::new(pattern)
|
let glob = Glob::new(pattern)
|
||||||
.or_else(|_| Glob::new(&format!("**/{pattern}")))
|
.or_else(|_| Glob::new(&format!("**/{pattern}")))
|
||||||
.map_err(|e| NotfilesError::Other(format!("invalid ignore pattern '{pattern}': {e}")))?;
|
.map_err(|e| {
|
||||||
|
NotfilesError::Glob(format!("invalid ignore pattern '{pattern}': {e}"))
|
||||||
|
})?;
|
||||||
builder.add(glob);
|
builder.add(glob);
|
||||||
// Also add a recursive variant so "foo" matches "a/foo" etc.
|
// Also add a recursive variant so "foo" matches "a/foo" etc.
|
||||||
#[allow(clippy::collapsible_if)]
|
#[allow(clippy::collapsible_if)]
|
||||||
@@ -26,7 +28,7 @@ impl IgnoreMatcher {
|
|||||||
}
|
}
|
||||||
let globset = builder
|
let globset = builder
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| NotfilesError::Other(format!("building ignore set: {e}")))?;
|
.map_err(|e| NotfilesError::Glob(format!("building ignore set: {e}")))?;
|
||||||
Ok(Self { globset })
|
Ok(Self { globset })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 cli;
|
||||||
|
pub mod detect;
|
||||||
pub mod ignore;
|
pub mod ignore;
|
||||||
pub mod linker;
|
pub mod linker;
|
||||||
pub mod package;
|
pub mod package;
|
||||||
|
pub mod ports;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub use linker::{LinkOptions, State};
|
use notcore::NotfilesError;
|
||||||
pub use package::resolve_packages;
|
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(
|
pub fn link(
|
||||||
dotfiles_dir: &Path,
|
dotfiles_dir: &Path,
|
||||||
packages: &[String],
|
packages: &[String],
|
||||||
opts: &LinkOptions,
|
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 config = notcore::Config::load(dotfiles_dir)?;
|
||||||
let mut state = State::load(dotfiles_dir)?;
|
config.validate()?;
|
||||||
let pkgs = resolve_packages(dotfiles_dir, packages)?;
|
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 {
|
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)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> Result<()> {
|
/// Remove managed symlinks/copies for all (or specified) packages.
|
||||||
let mut state = State::load(dotfiles_dir)?;
|
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() {
|
let pkgs = if packages.is_empty() {
|
||||||
state
|
state
|
||||||
.entries
|
.entries
|
||||||
@@ -39,8 +115,8 @@ pub fn unlink(dotfiles_dir: &Path, packages: &[String], opts: &LinkOptions) -> R
|
|||||||
packages.to_vec()
|
packages.to_vec()
|
||||||
};
|
};
|
||||||
for pkg in &pkgs {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde::{Deserialize, Serialize};
|
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 notcore::{Config, Method, NotfilesError, expand_tilde};
|
||||||
use crate::package::collect_files;
|
|
||||||
|
|
||||||
const STATE_FILE: &str = ".notfiles-state.toml";
|
const STATE_FILE: &str = ".notfiles-state.toml";
|
||||||
|
|
||||||
@@ -25,37 +26,51 @@ pub struct State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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);
|
let path = dotfiles_dir.join(STATE_FILE);
|
||||||
if !path.exists() {
|
if !fs.exists(&path) {
|
||||||
return Ok(State::default());
|
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}")))?;
|
.map_err(|e| NotfilesError::State(format!("reading state: {e}")))?;
|
||||||
let state: State = toml::from_str(&content)
|
let state: State = toml::from_str(&content)
|
||||||
.map_err(|e| NotfilesError::State(format!("parsing state: {e}")))?;
|
.map_err(|e| NotfilesError::State(format!("parsing state: {e}")))?;
|
||||||
Ok(state)
|
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 path = dotfiles_dir.join(STATE_FILE);
|
||||||
|
let tmp_path = dotfiles_dir.join(format!("{STATE_FILE}.tmp"));
|
||||||
let content = toml::to_string_pretty(self)
|
let content = toml::to_string_pretty(self)
|
||||||
.map_err(|e| NotfilesError::State(format!("serializing state: {e}")))?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn entries_for_package(&self, package: &str) -> Vec<&StateEntry> {
|
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) {
|
pub fn remove_package(&mut self, package: &str) {
|
||||||
self.entries.retain(|e| e.package != package);
|
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) {
|
pub fn add_entry(&mut self, entry: StateEntry) {
|
||||||
// Remove existing entry for same source+target, then add new
|
// 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);
|
self.entries.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,23 +82,39 @@ pub struct LinkOptions {
|
|||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct LinkResult {
|
||||||
|
pub package: String,
|
||||||
|
pub linked: usize,
|
||||||
|
pub copied: usize,
|
||||||
|
pub skipped: usize,
|
||||||
|
pub backed_up: usize,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn link_package(
|
pub fn link_package(
|
||||||
dotfiles_dir: &Path,
|
dotfiles_dir: &Path,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
state: &mut State,
|
state: &mut State,
|
||||||
package: &str,
|
package: &str,
|
||||||
opts: &LinkOptions,
|
opts: &LinkOptions,
|
||||||
) -> Result<(), NotfilesError> {
|
fs: &dyn FileStore,
|
||||||
|
reporter: &dyn Reporter,
|
||||||
|
) -> Result<LinkResult, NotfilesError> {
|
||||||
let package_dir = dotfiles_dir.join(package);
|
let package_dir = dotfiles_dir.join(package);
|
||||||
let method = config.method_for(package);
|
let method = config.method_for(package);
|
||||||
let target_base = expand_tilde(config.target_for(package))?;
|
let target_base = expand_tilde(config.target_for(package))?;
|
||||||
let files = collect_files(&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 files.is_empty() {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" {package}: no files to link");
|
source: package,
|
||||||
}
|
reason: "no files to link",
|
||||||
return Ok(());
|
});
|
||||||
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
for relative in &files {
|
for relative in &files {
|
||||||
@@ -91,52 +122,75 @@ pub fn link_package(
|
|||||||
let target = target_base.join(relative);
|
let target = target_base.join(relative);
|
||||||
let source_display = format!("{package}/{}", relative.display());
|
let source_display = format!("{package}/{}", relative.display());
|
||||||
|
|
||||||
// Check if already correctly linked
|
// Check if already correctly linked / copied
|
||||||
if is_already_linked(&source, &target, method) {
|
if is_already_linked(&source, &target, method, fs) {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" \x1b[90mskip\x1b[0m {source_display} (already linked)");
|
source: &source_display,
|
||||||
}
|
reason: "already linked",
|
||||||
|
});
|
||||||
|
result.skipped += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: save partial state then return an error.
|
||||||
|
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
|
// Conflict detection
|
||||||
if target.exists() || target.symlink_metadata().is_ok() {
|
if fs.exists(&target) || fs.symlink_metadata(&target).is_ok() {
|
||||||
if !opts.force {
|
if !opts.force {
|
||||||
return Err(NotfilesError::Conflict {
|
return save_and_return(
|
||||||
path: target.clone(),
|
state,
|
||||||
reason: format!(
|
NotfilesError::Conflict {
|
||||||
"already exists (use --force to overwrite); source: {source_display}"
|
path: target.clone(),
|
||||||
),
|
reason: format!(
|
||||||
});
|
"already exists (use --force to overwrite); source: {source_display}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Force mode: backup then remove
|
// Force mode: backup then remove
|
||||||
if !opts.no_backup {
|
if !opts.no_backup {
|
||||||
let backup = backup_path(&target);
|
let backup = backup_path(&target);
|
||||||
if opts.dry_run {
|
if opts.dry_run {
|
||||||
println!(" \x1b[33mwould backup\x1b[0m {} -> {}", target.display(), backup.display());
|
reporter.report(&LinkEvent::DryRun {
|
||||||
|
action: "backup",
|
||||||
|
source: &source_display,
|
||||||
|
target: &backup,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Backup {
|
||||||
println!(" \x1b[33mbackup\x1b[0m {} -> {}", target.display(), backup.display());
|
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 {
|
} else if !opts.dry_run {
|
||||||
if target.is_dir() {
|
let rm_result = if fs.is_dir(&target) {
|
||||||
fs::remove_dir_all(&target)?;
|
fs.remove_dir_all(&target)
|
||||||
} else {
|
} else {
|
||||||
fs::remove_file(&target)?;
|
fs.remove_file(&target)
|
||||||
|
};
|
||||||
|
if let Err(e) = rm_result {
|
||||||
|
return save_and_return(state, e.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create parent directories
|
// 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.dry_run {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::CreateDir { path: parent });
|
||||||
println!(" \x1b[90mwould create dir\x1b[0m {}", parent.display());
|
} else if let Err(e) = fs.create_dir_all(parent) {
|
||||||
}
|
return save_and_return(state, e.into());
|
||||||
} else {
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,21 +201,49 @@ pub fn link_package(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if opts.dry_run {
|
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 {
|
} else {
|
||||||
match method {
|
let io_result = match method {
|
||||||
Method::Symlink => {
|
Method::Symlink => {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
std::os::unix::fs::symlink(&source, &target)?;
|
{
|
||||||
|
fs.symlink(&source, &target).map(|_| 0u64)
|
||||||
|
}
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
fs::copy(&source, &target)?;
|
{
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Unsupported,
|
||||||
|
"symlink not supported on this platform",
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Method::Copy => {
|
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());
|
||||||
}
|
}
|
||||||
if opts.verbose {
|
match method {
|
||||||
println!(" \x1b[32m{action_word}\x1b[0m {source_display} -> {}", target.display());
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.add_entry(StateEntry {
|
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(
|
pub fn unlink_package(
|
||||||
_dotfiles_dir: &Path,
|
dotfiles_dir: &Path,
|
||||||
state: &mut State,
|
state: &mut State,
|
||||||
package: &str,
|
package: &str,
|
||||||
opts: &LinkOptions,
|
opts: &LinkOptions,
|
||||||
|
fs: &dyn FileStore,
|
||||||
|
reporter: &dyn Reporter,
|
||||||
) -> Result<(), NotfilesError> {
|
) -> 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 entries.is_empty() {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" {package}: nothing to unlink");
|
source: package,
|
||||||
}
|
reason: "nothing to unlink",
|
||||||
|
});
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut removed_entries = Vec::new();
|
||||||
|
|
||||||
for entry in &entries {
|
for entry in &entries {
|
||||||
let target = PathBuf::from(&entry.target);
|
let target = PathBuf::from(&entry.target);
|
||||||
|
let source = PathBuf::from(&entry.source);
|
||||||
|
|
||||||
if !target.exists() && target.symlink_metadata().is_err() {
|
if !fs.exists(&target) && fs.symlink_metadata(&target).is_err() {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" \x1b[90mskip\x1b[0m {} (already gone)", target.display());
|
source: &target.to_string_lossy(),
|
||||||
|
reason: "already gone",
|
||||||
|
});
|
||||||
|
if !opts.dry_run {
|
||||||
|
removed_entries.push((entry.source.clone(), entry.target.clone()));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -205,63 +314,185 @@ pub fn unlink_package(
|
|||||||
match entry.method {
|
match entry.method {
|
||||||
Method::Symlink => {
|
Method::Symlink => {
|
||||||
// Verify it's a symlink pointing to our source
|
// Verify it's a symlink pointing to our source
|
||||||
if let Ok(link_target) = fs::read_link(&target) {
|
if let Ok(link_target) = fs.read_link(&target) {
|
||||||
let source = PathBuf::from(&entry.source);
|
|
||||||
if link_target != source {
|
if link_target != source {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(
|
source: &target.to_string_lossy(),
|
||||||
" \x1b[33mskip\x1b[0m {} (symlink points elsewhere)",
|
reason: "symlink points elsewhere",
|
||||||
target.display()
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if opts.verbose {
|
reporter.report(&LinkEvent::Skip {
|
||||||
println!(" \x1b[33mskip\x1b[0m {} (not a symlink)", target.display());
|
source: &target.to_string_lossy(),
|
||||||
}
|
reason: "not a symlink",
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::Copy => {
|
Method::Copy => match (fs.read(&source), fs.read(&target)) {
|
||||||
// For copies, trust the state file
|
(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 {
|
if opts.dry_run {
|
||||||
println!(" \x1b[36mwould remove\x1b[0m {}", target.display());
|
reporter.report(&LinkEvent::DryRun {
|
||||||
|
action: "remove",
|
||||||
|
source: &target.to_string_lossy(),
|
||||||
|
target: &target,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if target.is_dir() {
|
if fs.is_dir(&target) {
|
||||||
fs::remove_dir_all(&target)?;
|
fs.remove_dir_all(&target)?;
|
||||||
} else {
|
} else {
|
||||||
fs::remove_file(&target)?;
|
fs.remove_file(&target)?;
|
||||||
}
|
|
||||||
if opts.verbose {
|
|
||||||
println!(" \x1b[31mremove\x1b[0m {}", target.display());
|
|
||||||
}
|
}
|
||||||
|
reporter.report(&LinkEvent::Remove { target: &target });
|
||||||
|
|
||||||
// Clean up empty parent dirs
|
// 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 {
|
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(())
|
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 {
|
match method {
|
||||||
Method::Symlink => {
|
Method::Symlink => {
|
||||||
if let Ok(link_target) = fs::read_link(target) {
|
if let Ok(link_target) = fs.read_link(target) {
|
||||||
link_target == source
|
link_target == source
|
||||||
} else {
|
} else {
|
||||||
false
|
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}"))
|
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();
|
let mut dir = path.parent();
|
||||||
while let Some(parent) = dir {
|
while let Some(parent) = dir {
|
||||||
// Stop at home dir or root
|
// Stop at home dir or root
|
||||||
if Some(parent.to_path_buf()) == dirs::home_dir() || parent == Path::new("/") {
|
if Some(parent.to_path_buf()) == dirs::home_dir() || parent == Path::new("/") {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if fs::read_dir(parent).map(|mut d| d.next().is_none()).unwrap_or(false) {
|
if fs
|
||||||
let _ = fs::remove_dir(parent);
|
.read_dir(parent)
|
||||||
|
.map(|children| children.is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
let _ = fs.remove_dir(parent);
|
||||||
dir = parent.parent();
|
dir = parent.parent();
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::Parser;
|
use clap::{CommandFactory, Parser};
|
||||||
use std::fs;
|
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::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<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
@@ -16,16 +19,35 @@ fn main() -> Result<()> {
|
|||||||
let dotfiles_dir = fs::canonicalize(&dotfiles_dir)
|
let dotfiles_dir = fs::canonicalize(&dotfiles_dir)
|
||||||
.with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?;
|
.with_context(|| format!("dotfiles directory not found: {}", dotfiles_dir.display()))?;
|
||||||
|
|
||||||
|
let reporter: Box<dyn Reporter> = if cli.json {
|
||||||
|
Box::new(JsonReporter)
|
||||||
|
} else {
|
||||||
|
Box::new(TerminalReporter)
|
||||||
|
};
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
|
Command::Detect => {
|
||||||
|
let home = std::env::var("HOME")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| dotfiles_dir.clone());
|
||||||
|
let managers = detect::detect(&home);
|
||||||
|
if cli.json {
|
||||||
|
detect::print_detected_json(&managers);
|
||||||
|
} else {
|
||||||
|
detect::print_detected(&managers);
|
||||||
|
}
|
||||||
|
}
|
||||||
Command::Init => cmd_init(&dotfiles_dir)?,
|
Command::Init => cmd_init(&dotfiles_dir)?,
|
||||||
Command::Link {
|
Command::Link {
|
||||||
force,
|
force,
|
||||||
no_backup,
|
no_backup,
|
||||||
packages,
|
packages,
|
||||||
} => {
|
} => {
|
||||||
|
let fs = &adapters::FileStoreImpl;
|
||||||
let config = Config::load(&dotfiles_dir)?;
|
let config = Config::load(&dotfiles_dir)?;
|
||||||
let mut state = State::load(&dotfiles_dir)?;
|
config.validate()?;
|
||||||
let pkgs = resolve_packages(&dotfiles_dir, &packages)?;
|
let mut state = State::load(&dotfiles_dir, fs)?;
|
||||||
|
let pkgs = resolve_packages_filtered_with_store(&dotfiles_dir, &packages, &config, fs)?;
|
||||||
let opts = LinkOptions {
|
let opts = LinkOptions {
|
||||||
force,
|
force,
|
||||||
no_backup,
|
no_backup,
|
||||||
@@ -37,16 +59,26 @@ fn main() -> Result<()> {
|
|||||||
println!("\x1b[36m(dry run)\x1b[0m");
|
println!("\x1b[36m(dry run)\x1b[0m");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
for pkg in &pkgs {
|
for pkg in &pkgs {
|
||||||
if cli.verbose || cli.dry_run {
|
if cli.verbose || cli.dry_run {
|
||||||
println!("Linking {pkg}...");
|
println!("Linking {pkg}...");
|
||||||
}
|
}
|
||||||
linker::link_package(&dotfiles_dir, &config, &mut state, pkg, &opts)?;
|
let result = linker::link_package(
|
||||||
|
&dotfiles_dir,
|
||||||
|
&config,
|
||||||
|
&mut state,
|
||||||
|
pkg,
|
||||||
|
&opts,
|
||||||
|
fs,
|
||||||
|
&*reporter,
|
||||||
|
)?;
|
||||||
|
results.push(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cli.dry_run {
|
if !cli.dry_run {
|
||||||
state.save(&dotfiles_dir)?;
|
state.save(&dotfiles_dir, fs)?;
|
||||||
let count: usize = pkgs.iter().map(|p| state.entries_for_package(p).len()).sum();
|
let count: usize = results.iter().map(|r| r.linked + r.copied).sum();
|
||||||
println!(
|
println!(
|
||||||
"\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m",
|
"\x1b[32mLinked {count} file{} across {} package{}.\x1b[0m",
|
||||||
if count == 1 { "" } else { "s" },
|
if count == 1 { "" } else { "s" },
|
||||||
@@ -56,9 +88,10 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Command::Unlink { packages } => {
|
Command::Unlink { packages } => {
|
||||||
|
let fs = &adapters::FileStoreImpl;
|
||||||
let config = Config::load(&dotfiles_dir)?;
|
let config = Config::load(&dotfiles_dir)?;
|
||||||
let _ = &config; // loaded but not needed for unlink
|
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() {
|
let pkgs = if packages.is_empty() {
|
||||||
state
|
state
|
||||||
.entries
|
.entries
|
||||||
@@ -69,7 +102,7 @@ fn main() -> Result<()> {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
} else {
|
} else {
|
||||||
// Validate requested packages exist in state
|
// 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
|
// Package dir might be gone but state entries exist — that's fine for unlink
|
||||||
Ok::<Vec<String>, anyhow::Error>(packages.clone())
|
Ok::<Vec<String>, anyhow::Error>(packages.clone())
|
||||||
});
|
});
|
||||||
@@ -90,11 +123,11 @@ fn main() -> Result<()> {
|
|||||||
if cli.verbose || cli.dry_run {
|
if cli.verbose || cli.dry_run {
|
||||||
println!("Unlinking {pkg}...");
|
println!("Unlinking {pkg}...");
|
||||||
}
|
}
|
||||||
linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts)?;
|
linker::unlink_package(&dotfiles_dir, &mut state, pkg, &opts, fs, &*reporter)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cli.dry_run {
|
if !cli.dry_run {
|
||||||
state.save(&dotfiles_dir)?;
|
state.save(&dotfiles_dir, fs)?;
|
||||||
println!(
|
println!(
|
||||||
"\x1b[32mUnlinked {} package{}.\x1b[0m",
|
"\x1b[32mUnlinked {} package{}.\x1b[0m",
|
||||||
pkgs.len(),
|
pkgs.len(),
|
||||||
@@ -102,14 +135,100 @@ 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 config = Config::load(&dotfiles_dir)?;
|
||||||
let state = State::load(&dotfiles_dir)?;
|
config.validate()?;
|
||||||
let pkgs = resolve_packages(&dotfiles_dir, &packages)?;
|
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 {
|
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_status(pkg, &entries);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,61 @@
|
|||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use notcore::{Config, NotfilesError};
|
use crate::adapters::FileStoreImpl;
|
||||||
use crate::ignore::IgnoreMatcher;
|
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).
|
/// Discover available packages (subdirectories of the dotfiles dir).
|
||||||
pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> {
|
pub fn discover_packages(dotfiles_dir: &Path) -> Result<Vec<String>, NotfilesError> {
|
||||||
|
discover_packages_with_store(dotfiles_dir, &FileStoreImpl)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discover_packages_with_store(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
fs: &dyn FileStore,
|
||||||
|
) -> Result<Vec<String>, NotfilesError> {
|
||||||
let mut packages = Vec::new();
|
let mut packages = Vec::new();
|
||||||
for entry in fs::read_dir(dotfiles_dir)? {
|
// TODO(#19): Replace implicit top-level directory discovery with an explicit
|
||||||
let entry = entry?;
|
// package contract so mixed-purpose repos do not accidentally link
|
||||||
let path = entry.path();
|
// operational directories like scripts/ or docs/.
|
||||||
if path.is_dir() {
|
for path in fs.read_dir(dotfiles_dir)? {
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
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
|
// Skip hidden dirs and common non-package dirs
|
||||||
if !name.starts_with('.') {
|
if !name.starts_with('.') {
|
||||||
packages.push(name);
|
packages.push(name);
|
||||||
@@ -28,13 +72,36 @@ pub fn resolve_packages(
|
|||||||
dotfiles_dir: &Path,
|
dotfiles_dir: &Path,
|
||||||
requested: &[String],
|
requested: &[String],
|
||||||
) -> Result<Vec<String>, NotfilesError> {
|
) -> 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() {
|
if requested.is_empty() {
|
||||||
return Ok(available);
|
return Ok(available);
|
||||||
}
|
}
|
||||||
for name in requested {
|
for name in requested {
|
||||||
if !available.contains(name) {
|
if !available.contains(name) {
|
||||||
return Err(NotfilesError::PackageNotFound { name: name.clone() });
|
let available_refs: Vec<&str> = available.iter().map(|s| s.as_str()).collect();
|
||||||
|
let suggestion = notcore::suggest_package(name, &available_refs);
|
||||||
|
let mut msg = name.clone();
|
||||||
|
if let Some(s) = suggestion {
|
||||||
|
msg = format!("{name} (did you mean '{s}'?)");
|
||||||
|
}
|
||||||
|
return Err(NotfilesError::PackageNotFound { name: msg });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(requested.to_vec())
|
Ok(requested.to_vec())
|
||||||
@@ -45,11 +112,20 @@ pub fn collect_files(
|
|||||||
package_dir: &Path,
|
package_dir: &Path,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
package_name: &str,
|
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> {
|
) -> Result<Vec<PathBuf>, NotfilesError> {
|
||||||
let patterns = config.ignore_patterns_for(package_name);
|
let patterns = config.ignore_patterns_for(package_name);
|
||||||
let matcher = IgnoreMatcher::new(&patterns)?;
|
let matcher = IgnoreMatcher::new(&patterns)?;
|
||||||
let mut files = Vec::new();
|
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();
|
files.sort();
|
||||||
Ok(files)
|
Ok(files)
|
||||||
}
|
}
|
||||||
@@ -59,18 +135,17 @@ fn walk_dir(
|
|||||||
current: &Path,
|
current: &Path,
|
||||||
matcher: &IgnoreMatcher,
|
matcher: &IgnoreMatcher,
|
||||||
files: &mut Vec<PathBuf>,
|
files: &mut Vec<PathBuf>,
|
||||||
|
fs: &dyn FileStore,
|
||||||
) -> Result<(), NotfilesError> {
|
) -> Result<(), NotfilesError> {
|
||||||
for entry in fs::read_dir(current)? {
|
for path in fs.read_dir(current)? {
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
let relative = path.strip_prefix(base).unwrap().to_path_buf();
|
let relative = path.strip_prefix(base).unwrap().to_path_buf();
|
||||||
|
|
||||||
if matcher.is_ignored(&relative) {
|
if matcher.is_ignored(&relative) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if path.is_dir() {
|
if fs.is_dir(&path) {
|
||||||
walk_dir(base, &path, matcher, files)?;
|
walk_dir(base, &path, matcher, files, fs)?;
|
||||||
} else {
|
} else {
|
||||||
files.push(relative);
|
files.push(relative);
|
||||||
}
|
}
|
||||||
@@ -81,6 +156,7 @@ fn walk_dir(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -94,6 +170,40 @@ mod tests {
|
|||||||
assert_eq!(pkgs, vec!["git", "zsh"]);
|
assert_eq!(pkgs, vec!["git", "zsh"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_discover_respects_include_filter() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("git")).unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("zsh")).unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("scripts")).unwrap();
|
||||||
|
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
include = ["git", "zsh"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
let pkgs = discover_packages_filtered(tmp.path(), &config).unwrap();
|
||||||
|
assert_eq!(pkgs, vec!["git", "zsh"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_discover_respects_exclude_filter() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("git")).unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("scripts")).unwrap();
|
||||||
|
fs::create_dir(tmp.path().join("docs")).unwrap();
|
||||||
|
|
||||||
|
let toml_str = r#"
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
exclude = ["scripts", "docs"]
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml_str).unwrap();
|
||||||
|
let pkgs = discover_packages_filtered(tmp.path(), &config).unwrap();
|
||||||
|
assert_eq!(pkgs, vec!["git"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_specific_packages() {
|
fn test_resolve_specific_packages() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
57
crates/notfiles/src/ports.rs
Normal file
57
crates/notfiles/src/ports.rs
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use notcore::{Config, Method, expand_tilde};
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::linker::State;
|
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)]
|
#[derive(Debug, PartialEq)]
|
||||||
pub enum FileStatus {
|
pub enum FileStatus {
|
||||||
@@ -37,6 +39,7 @@ pub fn package_status(
|
|||||||
config: &Config,
|
config: &Config,
|
||||||
state: &State,
|
state: &State,
|
||||||
package: &str,
|
package: &str,
|
||||||
|
fs: &dyn FileStore,
|
||||||
) -> Vec<StatusEntry> {
|
) -> Vec<StatusEntry> {
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
let package_dir = dotfiles_dir.join(package);
|
let package_dir = dotfiles_dir.join(package);
|
||||||
@@ -47,7 +50,7 @@ pub fn package_status(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Check files that should exist
|
// 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 {
|
for relative in &files {
|
||||||
let source = package_dir.join(relative);
|
let source = package_dir.join(relative);
|
||||||
let target = target_base.join(relative);
|
let target = target_base.join(relative);
|
||||||
@@ -55,26 +58,31 @@ pub fn package_status(
|
|||||||
|
|
||||||
let status = match method {
|
let status = match method {
|
||||||
Method::Symlink => {
|
Method::Symlink => {
|
||||||
if let Ok(link_target) = fs::read_link(&target) {
|
if let Ok(link_target) = fs.read_link(&target) {
|
||||||
if link_target == source {
|
if link_target == source {
|
||||||
FileStatus::Linked
|
FileStatus::Linked
|
||||||
} else {
|
} else {
|
||||||
FileStatus::Conflict
|
FileStatus::Conflict
|
||||||
}
|
}
|
||||||
} else if target.exists() {
|
} else if fs.exists(&target) {
|
||||||
FileStatus::Conflict
|
FileStatus::Conflict
|
||||||
} else {
|
} else {
|
||||||
FileStatus::Missing
|
FileStatus::Missing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Method::Copy => {
|
Method::Copy => {
|
||||||
let has_state = state.entries.iter().any(|e| {
|
let tracked = state.entries.iter().any(|e| {
|
||||||
e.package == package
|
e.package == package
|
||||||
&& e.target == target.to_string_lossy().as_ref()
|
&& 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
|
FileStatus::Copied
|
||||||
} else if target.exists() {
|
} else if fs.exists(&target) {
|
||||||
FileStatus::Conflict
|
FileStatus::Conflict
|
||||||
} else {
|
} else {
|
||||||
FileStatus::Missing
|
FileStatus::Missing
|
||||||
@@ -93,7 +101,7 @@ pub fn package_status(
|
|||||||
// Check for orphans: entries in state that no longer have a source file
|
// Check for orphans: entries in state that no longer have a source file
|
||||||
for entry in state.entries_for_package(package) {
|
for entry in state.entries_for_package(package) {
|
||||||
let source = PathBuf::from(&entry.source);
|
let source = PathBuf::from(&entry.source);
|
||||||
if !source.exists() {
|
if !fs.exists(&source) {
|
||||||
let target = PathBuf::from(&entry.target);
|
let target = PathBuf::from(&entry.target);
|
||||||
// Only add if we didn't already report this target
|
// Only add if we didn't already report this target
|
||||||
if !results.iter().any(|r| r.target == target) {
|
if !results.iter().any(|r| r.target == target) {
|
||||||
@@ -115,6 +123,126 @@ pub fn package_status(
|
|||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub enum DiffKind {
|
||||||
|
Identical,
|
||||||
|
Modified,
|
||||||
|
SourceOnly,
|
||||||
|
TargetOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DiffEntry {
|
||||||
|
pub source_display: String,
|
||||||
|
pub target: PathBuf,
|
||||||
|
pub kind: DiffKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn diff_package(
|
||||||
|
dotfiles_dir: &Path,
|
||||||
|
config: &Config,
|
||||||
|
state: &State,
|
||||||
|
package: &str,
|
||||||
|
fs: &dyn FileStore,
|
||||||
|
) -> Vec<DiffEntry> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let method = config.method_for(package);
|
||||||
|
if method != Method::Copy {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
let package_dir = dotfiles_dir.join(package);
|
||||||
|
let target_base = match expand_tilde(config.target_for(package)) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => return results,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check files tracked in state for this package
|
||||||
|
for entry in state.entries_for_package(package) {
|
||||||
|
let source = PathBuf::from(&entry.source);
|
||||||
|
let target = PathBuf::from(&entry.target);
|
||||||
|
let source_display = format!(
|
||||||
|
"{package}/{}",
|
||||||
|
source
|
||||||
|
.strip_prefix(&package_dir)
|
||||||
|
.unwrap_or(&source)
|
||||||
|
.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
let kind = match (fs.read(&source), fs.read(&target)) {
|
||||||
|
(Ok(src), Ok(tgt)) => {
|
||||||
|
if src == tgt {
|
||||||
|
DiffKind::Identical
|
||||||
|
} else {
|
||||||
|
DiffKind::Modified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Ok(_), Err(_)) => DiffKind::SourceOnly,
|
||||||
|
(Err(_), Ok(_)) => DiffKind::TargetOnly,
|
||||||
|
(Err(_), Err(_)) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push(DiffEntry {
|
||||||
|
source_display,
|
||||||
|
target,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check untracked files from the package dir
|
||||||
|
if let Ok(files) = collect_files_with_store(&package_dir, config, package, fs) {
|
||||||
|
for relative in &files {
|
||||||
|
let source = package_dir.join(relative);
|
||||||
|
let target = target_base.join(relative);
|
||||||
|
// Skip if already covered by state entries
|
||||||
|
if results.iter().any(|r| r.target == target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let source_display = format!("{package}/{}", relative.display());
|
||||||
|
let kind = if !fs.exists(&target) {
|
||||||
|
DiffKind::SourceOnly
|
||||||
|
} else {
|
||||||
|
match (fs.read(&source), fs.read(&target)) {
|
||||||
|
(Ok(src), Ok(tgt)) if src == tgt => DiffKind::Identical,
|
||||||
|
(Ok(_), Ok(_)) => DiffKind::Modified,
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
results.push(DiffEntry {
|
||||||
|
source_display,
|
||||||
|
target,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_diff(package: &str, entries: &[DiffEntry]) {
|
||||||
|
let non_identical: Vec<_> = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.kind != DiffKind::Identical)
|
||||||
|
.collect();
|
||||||
|
if non_identical.is_empty() {
|
||||||
|
println!(" {package}: all copies identical");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
println!(" \x1b[1m{package}\x1b[0m:");
|
||||||
|
for entry in &non_identical {
|
||||||
|
let label = match entry.kind {
|
||||||
|
DiffKind::Modified => "\x1b[33mmodified\x1b[0m",
|
||||||
|
DiffKind::SourceOnly => "\x1b[36msource only\x1b[0m",
|
||||||
|
DiffKind::TargetOnly => "\x1b[35mtarget only\x1b[0m",
|
||||||
|
DiffKind::Identical => unreachable!(),
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {label} {} -> {}",
|
||||||
|
entry.source_display,
|
||||||
|
entry.target.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn print_status(package: &str, entries: &[StatusEntry]) {
|
pub fn print_status(package: &str, entries: &[StatusEntry]) {
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
println!(" {package}: (empty)");
|
println!(" {package}: (empty)");
|
||||||
@@ -122,6 +250,26 @@ pub fn print_status(package: &str, entries: &[StatusEntry]) {
|
|||||||
}
|
}
|
||||||
println!(" \x1b[1m{package}\x1b[0m:");
|
println!(" \x1b[1m{package}\x1b[0m:");
|
||||||
for entry in entries {
|
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());
|
||||||
|
}
|
||||||
|
|||||||
23
crates/notfiles/tests/fixtures/dotfiles/README.md
vendored
Normal file
23
crates/notfiles/tests/fixtures/dotfiles/README.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Test Fixture — Dotfiles
|
||||||
|
|
||||||
|
This directory is a test fixture representing a realistic dotfiles repo.
|
||||||
|
It is used by notfiles integration tests and serves as an example of the
|
||||||
|
expected directory structure.
|
||||||
|
|
||||||
|
## Package directories (linked to ~)
|
||||||
|
|
||||||
|
- `git/` — `.gitconfig` at root
|
||||||
|
- `zsh/` — `.zshrc` at root
|
||||||
|
- `nushell/` — `.config/nushell/` tree
|
||||||
|
- `starship/` — `.config/starship.toml`
|
||||||
|
- `alacritty/` — `.config/alacritty/alacritty.toml`
|
||||||
|
- `mise/` — `.config/mise/config.toml`
|
||||||
|
- `fish/` — `.config/fish/config.fish`
|
||||||
|
- `nixos/` — platform-gated to Linux only
|
||||||
|
- `vscode/` — deep `Library/Application Support/` path (macOS)
|
||||||
|
|
||||||
|
## Non-package directories (excluded via include list)
|
||||||
|
|
||||||
|
- `scripts/` — shell scripts, not a stow target
|
||||||
|
- `docs/` — documentation
|
||||||
|
- `secrets/` — encrypted env files, copy-only with ignore
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/alacritty/.config/alacritty/alacritty.toml
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/alacritty/.config/alacritty/alacritty.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[font]
|
||||||
|
size = 14.0
|
||||||
3
crates/notfiles/tests/fixtures/dotfiles/docs/bootstrap-runbook.md
vendored
Normal file
3
crates/notfiles/tests/fixtures/dotfiles/docs/bootstrap-runbook.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Bootstrap Runbook
|
||||||
|
|
||||||
|
Steps for setting up a new machine.
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/fish/.config/fish/config.fish
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/fish/.config/fish/config.fish
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Example fish config
|
||||||
|
set -gx EDITOR nvim
|
||||||
5
crates/notfiles/tests/fixtures/dotfiles/git/.gitconfig
vendored
Normal file
5
crates/notfiles/tests/fixtures/dotfiles/git/.gitconfig
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[user]
|
||||||
|
name = Example User
|
||||||
|
email = user@example.com
|
||||||
|
[init]
|
||||||
|
defaultBranch = main
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/mise/.config/mise/config.toml
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/mise/.config/mise/config.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[tools]
|
||||||
|
node = "lts"
|
||||||
4
crates/notfiles/tests/fixtures/dotfiles/nixos/configuration.nix
vendored
Normal file
4
crates/notfiles/tests/fixtures/dotfiles/nixos/configuration.nix
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{ config, pkgs, ... }:
|
||||||
|
{
|
||||||
|
environment.systemPackages = with pkgs; [ vim git ];
|
||||||
|
}
|
||||||
34
crates/notfiles/tests/fixtures/dotfiles/notfiles.toml
vendored
Normal file
34
crates/notfiles/tests/fixtures/dotfiles/notfiles.toml
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Example notfiles.toml — mirrors a realistic dotfiles repo.
|
||||||
|
#
|
||||||
|
# This fixture is used by integration tests and serves as documentation
|
||||||
|
# for how to configure notfiles with a mixed-purpose dotfiles repo.
|
||||||
|
|
||||||
|
[defaults]
|
||||||
|
target = "~"
|
||||||
|
ignore = [".git", ".DS_Store", "README.md", "LICENSE", "notfiles.toml", ".notfiles-state.toml", ".nothooks-state.toml"]
|
||||||
|
|
||||||
|
# Only link actual stow packages — skip operational dirs.
|
||||||
|
include = ["git", "zsh", "nushell", "starship", "alacritty", "mise", "fish", "nixos", "vscode"]
|
||||||
|
|
||||||
|
[packages.git]
|
||||||
|
|
||||||
|
[packages.zsh]
|
||||||
|
|
||||||
|
[packages.nushell]
|
||||||
|
|
||||||
|
[packages.starship]
|
||||||
|
|
||||||
|
[packages.alacritty]
|
||||||
|
|
||||||
|
[packages.mise]
|
||||||
|
|
||||||
|
[packages.fish]
|
||||||
|
|
||||||
|
[packages.nixos]
|
||||||
|
platforms = ["linux"]
|
||||||
|
|
||||||
|
[packages.vscode]
|
||||||
|
|
||||||
|
[packages.secrets]
|
||||||
|
method = "copy"
|
||||||
|
ignore = ["*.example"]
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/autoload/aliases.nu
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/autoload/aliases.nu
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Example aliases
|
||||||
|
alias ll = ls -l
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/config.nu
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/config.nu
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Example nushell config
|
||||||
|
$env.config.show_banner = false
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/env.nu
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/nushell/.config/nushell/env.nu
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Example nushell env
|
||||||
|
$env.EDITOR = "nvim"
|
||||||
3
crates/notfiles/tests/fixtures/dotfiles/scripts/doctor.sh
vendored
Normal file
3
crates/notfiles/tests/fixtures/dotfiles/scripts/doctor.sh
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Health check script — NOT a stow package.
|
||||||
|
echo "All good"
|
||||||
3
crates/notfiles/tests/fixtures/dotfiles/scripts/lib/common.sh
vendored
Normal file
3
crates/notfiles/tests/fixtures/dotfiles/scripts/lib/common.sh
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared helpers — NOT a stow package.
|
||||||
|
log() { echo "[$(date)] $*"; }
|
||||||
3
crates/notfiles/tests/fixtures/dotfiles/secrets/.env.json.example
vendored
Normal file
3
crates/notfiles/tests/fixtures/dotfiles/secrets/.env.json.example
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"EXAMPLE_KEY": "replace-me"
|
||||||
|
}
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/starship/.config/starship.toml
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/starship/.config/starship.toml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[character]
|
||||||
|
success_symbol = "[>](bold green)"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"editor.fontSize": 14,
|
||||||
|
"editor.tabSize": 2
|
||||||
|
}
|
||||||
2
crates/notfiles/tests/fixtures/dotfiles/zsh/.zshrc
vendored
Normal file
2
crates/notfiles/tests/fixtures/dotfiles/zsh/.zshrc
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Example zshrc
|
||||||
|
export EDITOR=nvim
|
||||||
@@ -94,7 +94,13 @@ fn test_link_and_status() {
|
|||||||
|
|
||||||
let gitconfig = target.join(".gitconfig");
|
let gitconfig = target.join(".gitconfig");
|
||||||
assert!(gitconfig.exists());
|
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
|
// State file should exist
|
||||||
assert!(dotfiles.join(".notfiles-state.toml").exists());
|
assert!(dotfiles.join(".notfiles-state.toml").exists());
|
||||||
@@ -199,7 +205,14 @@ fn test_force_with_backup() {
|
|||||||
assert!(ok);
|
assert!(ok);
|
||||||
|
|
||||||
// The link should now exist
|
// 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
|
// A backup should exist
|
||||||
let backups: Vec<_> = fs::read_dir(&target)
|
let backups: Vec<_> = fs::read_dir(&target)
|
||||||
@@ -232,11 +245,7 @@ fn test_force_no_backup() {
|
|||||||
let backups: Vec<_> = fs::read_dir(&target)
|
let backups: Vec<_> = fs::read_dir(&target)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.filter(|e| {
|
.filter(|e| e.file_name().to_string_lossy().contains("notfiles-backup"))
|
||||||
e.file_name()
|
|
||||||
.to_string_lossy()
|
|
||||||
.contains("notfiles-backup")
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(backups.len(), 0);
|
assert_eq!(backups.len(), 0);
|
||||||
}
|
}
|
||||||
@@ -272,8 +281,17 @@ method = "copy"
|
|||||||
let ssh_config = target.join(".ssh/config");
|
let ssh_config = target.join(".ssh/config");
|
||||||
assert!(ssh_config.exists());
|
assert!(ssh_config.exists());
|
||||||
// Should NOT be a symlink
|
// Should NOT be a symlink
|
||||||
assert!(!ssh_config.symlink_metadata().unwrap().file_type().is_symlink());
|
assert!(
|
||||||
assert_eq!(fs::read_to_string(&ssh_config).unwrap(), "Host *\n AddKeysToAgent yes");
|
!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"
|
// Status should show "copied"
|
||||||
let (stdout, _, _) = run(&dotfiles, &["status", "ssh"]);
|
let (stdout, _, _) = run(&dotfiles, &["status", "ssh"]);
|
||||||
@@ -285,6 +303,89 @@ method = "copy"
|
|||||||
assert!(!ssh_config.exists());
|
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]
|
#[test]
|
||||||
fn test_dry_run() {
|
fn test_dry_run() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
@@ -362,3 +463,164 @@ fn test_unlink_cleans_empty_dirs() {
|
|||||||
// The .config/zsh directory should be cleaned up
|
// The .config/zsh directory should be cleaned up
|
||||||
assert!(!target.join(".config/zsh").exists());
|
assert!(!target.join(".config/zsh").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fixture-based tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Copy the static fixture into a temp dir with a rewritten target, so tests
|
||||||
|
/// don't touch the real home directory.
|
||||||
|
fn setup_from_fixture(tmp: &TempDir) {
|
||||||
|
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/dotfiles");
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
fs::create_dir_all(&target).unwrap();
|
||||||
|
copy_dir_recursive(&fixture, &dotfiles).unwrap();
|
||||||
|
|
||||||
|
// Rewrite notfiles.toml to point target at our temp home
|
||||||
|
let config_path = dotfiles.join("notfiles.toml");
|
||||||
|
let content = fs::read_to_string(&config_path).unwrap();
|
||||||
|
let rewritten = content.replace(
|
||||||
|
"target = \"~\"",
|
||||||
|
&format!("target = \"{}\"", target.display()),
|
||||||
|
);
|
||||||
|
fs::write(&config_path, rewritten).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
|
fs::create_dir_all(dst)?;
|
||||||
|
for entry in fs::read_dir(src)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let src_path = entry.path();
|
||||||
|
let dst_path = dst.join(entry.file_name());
|
||||||
|
if src_path.is_dir() {
|
||||||
|
copy_dir_recursive(&src_path, &dst_path)?;
|
||||||
|
} else {
|
||||||
|
fs::copy(&src_path, &dst_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_include_excludes_non_packages() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["link"]);
|
||||||
|
assert!(ok);
|
||||||
|
|
||||||
|
// Stow packages should be linked
|
||||||
|
assert!(
|
||||||
|
target.join(".gitconfig").exists(),
|
||||||
|
".gitconfig should be linked"
|
||||||
|
);
|
||||||
|
assert!(target.join(".zshrc").exists(), ".zshrc should be linked");
|
||||||
|
assert!(
|
||||||
|
target.join(".config/nushell/config.nu").exists(),
|
||||||
|
"nushell config should be linked",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
target.join(".config/starship.toml").exists(),
|
||||||
|
"starship config should be linked",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Non-package dirs should NOT be linked
|
||||||
|
assert!(
|
||||||
|
!target.join("lib/common.sh").exists(),
|
||||||
|
"scripts/ should not be linked",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!target.join("bootstrap-runbook.md").exists(),
|
||||||
|
"docs/ should not be linked",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!target.join("doctor.sh").exists(),
|
||||||
|
"scripts/ should not be linked",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_status_shows_all_packages() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
|
||||||
|
let (stdout, _, ok) = run(&dotfiles, &["status"]);
|
||||||
|
assert!(ok);
|
||||||
|
|
||||||
|
// Should list stow packages
|
||||||
|
assert!(stdout.contains("git"), "status should show git package");
|
||||||
|
assert!(stdout.contains("zsh"), "status should show zsh package");
|
||||||
|
assert!(
|
||||||
|
stdout.contains("nushell"),
|
||||||
|
"status should show nushell package"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should NOT list excluded dirs
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("scripts"),
|
||||||
|
"status should not show scripts"
|
||||||
|
);
|
||||||
|
assert!(!stdout.contains("docs"), "status should not show docs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_deep_paths_link_correctly() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["link", "vscode"]);
|
||||||
|
assert!(ok);
|
||||||
|
|
||||||
|
let vscode_settings = target.join("Library/Application Support/Code/User/settings.json");
|
||||||
|
assert!(
|
||||||
|
vscode_settings.exists(),
|
||||||
|
"deep path should be linked: {}",
|
||||||
|
vscode_settings.display(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_platform_filter_skips_linux_on_macos() {
|
||||||
|
if !cfg!(target_os = "macos") {
|
||||||
|
return; // This test is macOS-specific
|
||||||
|
}
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["link"]);
|
||||||
|
assert!(ok);
|
||||||
|
|
||||||
|
// nixos has platforms = ["linux"], so it should NOT be linked on macOS
|
||||||
|
assert!(
|
||||||
|
!target.join("configuration.nix").exists(),
|
||||||
|
"nixos package should be skipped on macOS",
|
||||||
|
);
|
||||||
|
|
||||||
|
// But macos-compatible packages should be linked
|
||||||
|
assert!(target.join(".gitconfig").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fixture_link_and_unlink_round_trip() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
setup_from_fixture(&tmp);
|
||||||
|
let dotfiles = tmp.path().join("dotfiles");
|
||||||
|
let target = tmp.path().join("home");
|
||||||
|
|
||||||
|
// Link all
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["link"]);
|
||||||
|
assert!(ok);
|
||||||
|
assert!(target.join(".gitconfig").exists());
|
||||||
|
|
||||||
|
// Unlink all
|
||||||
|
let (_, _, ok) = run(&dotfiles, &["unlink"]);
|
||||||
|
assert!(ok);
|
||||||
|
assert!(!target.join(".gitconfig").exists());
|
||||||
|
assert!(!target.join(".zshrc").exists());
|
||||||
|
}
|
||||||
|
|||||||
33
crates/notforge/Cargo.toml
Normal file
33
crates/notforge/Cargo.toml
Normal 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 }
|
||||||
176
crates/notforge/src/config.rs
Normal file
176
crates/notforge/src/config.rs
Normal 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())),
|
||||||
|
)
|
||||||
|
}
|
||||||
36
crates/notforge/src/error.rs
Normal file
36
crates/notforge/src/error.rs
Normal 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),
|
||||||
|
}
|
||||||
302
crates/notforge/src/gitea.rs
Normal file
302
crates/notforge/src/gitea.rs
Normal 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 })
|
||||||
|
}
|
||||||
19
crates/notforge/src/lib.rs
Normal file
19
crates/notforge/src/lib.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
3
crates/notforge/src/main.rs
Normal file
3
crates/notforge/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("{}", notforge::VERSION);
|
||||||
|
}
|
||||||
126
crates/notforge/src/ports.rs
Normal file
126
crates/notforge/src/ports.rs
Normal 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>;
|
||||||
|
}
|
||||||
222
crates/notforge/tests/api.rs
Normal file
222
crates/notforge/tests/api.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
203
crates/notforge/tests/gitea.rs
Normal file
203
crates/notforge/tests/gitea.rs
Normal 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
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
25
crates/notgraph/Cargo.toml
Normal file
25
crates/notgraph/Cargo.toml
Normal 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"
|
||||||
205
crates/notgraph/src/analysis.rs
Normal file
205
crates/notgraph/src/analysis.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
59
crates/notgraph/src/crate_graph.rs
Normal file
59
crates/notgraph/src/crate_graph.rs
Normal 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
357
crates/notgraph/src/emit.rs
Normal 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(" → ")))
|
||||||
|
.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(())
|
||||||
|
}
|
||||||
6
crates/notgraph/src/lib.rs
Normal file
6
crates/notgraph/src/lib.rs
Normal 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
100
crates/notgraph/src/main.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
102
crates/notgraph/src/module_graph.rs
Normal file
102
crates/notgraph/src/module_graph.rs
Normal 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()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
crates/notgraph/src/symbols.rs
Normal file
180
crates/notgraph/src/symbols.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
crates/notgraph/src/types.rs
Normal file
114
crates/notgraph/src/types.rs
Normal 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("::")
|
||||||
|
}
|
||||||
87
crates/notgraph/templates/report.html.template
Normal file
87
crates/notgraph/templates/report.html.template
Normal 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>
|
||||||
1
crates/notgraph/tests/fixtures/clean/src/alpha.rs
vendored
Normal file
1
crates/notgraph/tests/fixtures/clean/src/alpha.rs
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub fn hello() -> &'static str { "alpha" }
|
||||||
1
crates/notgraph/tests/fixtures/clean/src/beta.rs
vendored
Normal file
1
crates/notgraph/tests/fixtures/clean/src/beta.rs
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub fn greet() -> &'static str { "beta" }
|
||||||
2
crates/notgraph/tests/fixtures/clean/src/lib.rs
vendored
Normal file
2
crates/notgraph/tests/fixtures/clean/src/lib.rs
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod alpha;
|
||||||
|
pub mod beta;
|
||||||
1
crates/notgraph/tests/fixtures/cyclic/src/bar.rs
vendored
Normal file
1
crates/notgraph/tests/fixtures/cyclic/src/bar.rs
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod foo;
|
||||||
1
crates/notgraph/tests/fixtures/cyclic/src/foo.rs
vendored
Normal file
1
crates/notgraph/tests/fixtures/cyclic/src/foo.rs
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod bar;
|
||||||
2
crates/notgraph/tests/fixtures/cyclic/src/lib.rs
vendored
Normal file
2
crates/notgraph/tests/fixtures/cyclic/src/lib.rs
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod foo;
|
||||||
|
pub mod bar;
|
||||||
7
crates/notgraph/tests/fixtures/symbols/src/lib.rs
vendored
Normal file
7
crates/notgraph/tests/fixtures/symbols/src/lib.rs
vendored
Normal 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;
|
||||||
239
crates/notgraph/tests/integration.rs
Normal file
239
crates/notgraph/tests/integration.rs
Normal 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<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,11 +13,11 @@ name = "nothooks"
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
notcore = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
notcore = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
clap = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
121
crates/nothooks/README.md
Normal file
121
crates/nothooks/README.md
Normal 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.
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
pub mod runner;
|
pub mod runner;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
|
use notcore::{HookPhase, HookSpec, Report};
|
||||||
pub use runner::HookRunner;
|
pub use runner::HookRunner;
|
||||||
use notcore::{HookPhase, HookSpec, Report, StepStatus};
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
pub enum HookResult {
|
pub enum HookResult {
|
||||||
@@ -12,20 +12,12 @@ pub enum HookResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run all hooks matching `phase` and collect into a `Report`.
|
/// 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(
|
pub fn run_phase(
|
||||||
hooks: &[HookSpec],
|
hooks: &[HookSpec],
|
||||||
phase: &HookPhase,
|
phase: &HookPhase,
|
||||||
runner: &HookRunner,
|
runner: &HookRunner,
|
||||||
) -> Report {
|
) -> anyhow::Result<Report> {
|
||||||
let mut report = Report::default();
|
runner.run_phase(hooks, phase)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use nothooks::{run_phase, HookRunner};
|
|
||||||
use notcore::{HookPhase, HookSpec};
|
use notcore::{HookPhase, HookSpec};
|
||||||
|
use nothooks::{HookRunner, run_phase};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -62,7 +62,7 @@ fn main() -> Result<()> {
|
|||||||
HookRunner::new(state_dir)
|
HookRunner::new(state_dir)
|
||||||
};
|
};
|
||||||
|
|
||||||
let report = run_phase(&file.hooks, &phase, &runner);
|
let report = run_phase(&file.hooks, &phase, &runner)?;
|
||||||
report.print();
|
report.print();
|
||||||
|
|
||||||
if report.has_failures() {
|
if report.has_failures() {
|
||||||
|
|||||||
@@ -1,8 +1,31 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
use std::process::Command;
|
|
||||||
use notcore::{HookPhase, HookSpec};
|
|
||||||
use crate::HookResult;
|
use crate::HookResult;
|
||||||
use crate::state::HookState;
|
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 {
|
pub struct HookRunner {
|
||||||
state_dir: PathBuf,
|
state_dir: PathBuf,
|
||||||
@@ -11,29 +34,77 @@ pub struct HookRunner {
|
|||||||
|
|
||||||
impl HookRunner {
|
impl HookRunner {
|
||||||
pub fn new(state_dir: PathBuf) -> Self {
|
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 {
|
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 {
|
/// Run all hooks in `hooks` that match `phase`.
|
||||||
let mut state = HookState::load(&self.state_dir).unwrap_or_default();
|
///
|
||||||
|
/// 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) {
|
if spec.phase == HookPhase::Setup && !self.force && state.is_done(&spec.name) {
|
||||||
return HookResult::Skipped;
|
return HookResult::Skipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = Command::new("nu")
|
let interp = match resolve_interpreter(spec) {
|
||||||
.arg(&spec.script)
|
Ok(i) => i,
|
||||||
.status();
|
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 {
|
match result {
|
||||||
Ok(status) if status.success() => {
|
Ok(status) if status.success() => {
|
||||||
if spec.phase == HookPhase::Setup {
|
if spec.phase == HookPhase::Setup {
|
||||||
state.mark_done(&spec.name);
|
state.mark_done(&spec.name);
|
||||||
let _ = state.save(&self.state_dir);
|
*state_dirty = true;
|
||||||
}
|
}
|
||||||
HookResult::Ok
|
HookResult::Ok
|
||||||
}
|
}
|
||||||
@@ -41,4 +112,30 @@ impl HookRunner {
|
|||||||
Err(e) => HookResult::Failed(e.to_string()),
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
use std::path::Path;
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
const STATE_FILE: &str = ".nothooks-state.toml";
|
const STATE_FILE: &str = ".nothooks-state.toml";
|
||||||
|
|
||||||
@@ -22,7 +22,9 @@ impl HookState {
|
|||||||
|
|
||||||
pub fn save(&self, dir: &Path) -> Result<()> {
|
pub fn save(&self, dir: &Path) -> Result<()> {
|
||||||
let path = dir.join(STATE_FILE);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +1,82 @@
|
|||||||
|
use notcore::{HookPhase, HookSpec};
|
||||||
|
use nothooks::{HookResult, HookRunner};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use nothooks::{HookResult, HookRunner};
|
|
||||||
use notcore::{HookPhase, HookSpec};
|
|
||||||
|
|
||||||
fn make_hook_script(dir: &TempDir, name: &str, content: &str) -> 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();
|
fs::write(&path, content).unwrap();
|
||||||
HookSpec {
|
HookSpec {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
script: path.to_str().unwrap().to_string(),
|
script: path.to_str().unwrap().to_string(),
|
||||||
phase: HookPhase::Dot,
|
phase: HookPhase::Dot,
|
||||||
|
interpreter: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_hook_success() {
|
fn test_hook_success() {
|
||||||
let dir = TempDir::new().unwrap();
|
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 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));
|
assert!(matches!(result, HookResult::Ok));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_hook_failure() {
|
fn test_hook_failure() {
|
||||||
let dir = TempDir::new().unwrap();
|
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 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(_)));
|
assert!(matches!(result, HookResult::Failed(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_setup_hook_skipped_on_rerun() {
|
fn test_setup_hook_skipped_on_rerun() {
|
||||||
let dir = TempDir::new().unwrap();
|
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;
|
spec.phase = notcore::HookPhase::Setup;
|
||||||
|
|
||||||
let runner = HookRunner::new(dir.path().to_path_buf());
|
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));
|
assert!(matches!(r1, HookResult::Ok));
|
||||||
|
|
||||||
// Second run — should be skipped
|
// Second run — should be skipped
|
||||||
let r2 = runner.run_hook(&spec);
|
let r2 = runner.run_hook(&spec).unwrap();
|
||||||
assert!(matches!(r2, HookResult::Skipped));
|
assert!(matches!(r2, HookResult::Skipped));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_setup_hook_force_reruns() {
|
fn test_setup_hook_force_reruns() {
|
||||||
let dir = TempDir::new().unwrap();
|
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;
|
spec.phase = notcore::HookPhase::Setup;
|
||||||
|
|
||||||
let runner = HookRunner::new(dir.path().to_path_buf());
|
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 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));
|
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
Reference in New Issue
Block a user