diff --git a/AGENTS.md b/AGENTS.md index be636f3..ae3d88c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ NotesCraft is a native, Obsidian-compatible notes app written in **Rust only** ( - One `thiserror` enum `Error` and `pub type Result` per crate. Libraries do not use `anyhow`. - Every public item has rustdoc, every module has a `//!` comment. `clippy::pedantic` must be clean; no blanket allows. - Files stay under about 400 lines; split by concern. Smallest design that works; no speculative abstractions. +- **Source folders stay shallow and grouped**: no directory under a crate's `src/` (or an app's) holds more than 12 loose `.rs` files (`mod.rs` and `lib.rs` do not count). Group files into sub-folders by concern (`foo.rs` + `foo/`), and nest a group inside a parent folder when it grows. Moving files must not change public paths: re-export from `lib.rs`. Enforced by `cargo xtask tree` (part of `cargo xtask ci`); directories still waiting for their restructure are listed in `xtask/src/tree.rs` and in ROADMAP.md. - Named exports, `import type`-style discipline, no magic numbers (named constants), `get` = derived, `fetch` = IO, `is`/`has`/`can` = predicate. - Tests: unit in `src`, integration in `tests/`, benches/perf in `perf/` or `#[ignore]`d tests, examples in `examples/`. Write the failing test first. Every crate has at least one. - Every write to disk goes through `craft-atomic`. diff --git a/ROADMAP.md b/ROADMAP.md index 7bed4c2..8d5f8cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,10 +31,12 @@ Also built beyond the phase list: Vim mode (`vim` crate and app-wide navigation) ## Next up -1. First tagged release: the macOS `.dmg` dry run passes locally (ad-hoc signed, signature valid from the mounted image, app starts); still to do: run `release.yml` on the remote (signing secrets, the shared `craft-common-actions` workflow), then install-test the Windows MSI and the Linux packages on real machines; embed the app icon into the Windows `.exe` (needs a build dependency such as `winresource`, owner approval). The runtime window icon is set on Windows and Linux. -2. Tab row clears the macOS traffic lights when the sidebar is collapsed to its rail; Visual-mode edits have no per-block caret memory across undo. -3. Binary clipboard image paste (needs the `arboard` dependency approved), `craft-print` hook for PDF export, typeset math in PDF. -4. Housekeeping: `craftrules` reconciliation (0.10), regex dependency decision for search, `testkit`. +1. Assistant overhaul (in progress on branch `feat/assistant-overhaul`): several connections (ACP agents and OpenAI-compatible endpoints), a connections manager in Settings, `@` mentions and context chips, "Add to chat" from the Source and Visual selections, a new panel layout, static agent icons. +2. Folder restructure of the remaining crowded source directories, then remove their entries from `PENDING` in `xtask/src/tree.rs`: `crates/ui-egui/src` (53 loose files) and its `app/`, `text/`, `whiteboard_view/`, `assistant_panel/` folders, and `crates/engine/src` (47). Also `crates/acp`. Run after item 1 lands so moves do not collide; keep public paths stable through re-exports; update `docs/ARCHITECTURE.md` with the new trees. Every other crate is already grouped (rule in `AGENTS.md`, checked by `cargo xtask tree`). +3. First tagged release: the macOS `.dmg` dry run passes locally (ad-hoc signed, signature valid from the mounted image, app starts); still to do: run `release.yml` on the remote (signing secrets, the shared `craft-common-actions` workflow), then install-test the Windows MSI and the Linux packages on real machines; embed the app icon into the Windows `.exe` (needs a build dependency such as `winresource`, owner approval). The runtime window icon is set on Windows and Linux. +4. Polish: the tab row clears the macOS traffic lights when the sidebar is collapsed to its rail; animated icons: author the set in EffectCraft instead of generating it (its Lottie export adds fields such as `ml` that the player rejects) and tune individual icons as they are noticed (`notescraft-icon-motion` skill); egui's own menus and tooltips still close without animation. +5. Binary clipboard image paste (needs the `arboard` dependency approved), `craft-print` hook for PDF export, typeset math in PDF. +6. Housekeeping: `craftrules` reconciliation (0.10), regex dependency decision for search, `testkit`, split `crates/markdown/src/lib.rs` (568 lines). ## Known issues and review findings diff --git a/xtask/src/main.rs b/xtask/src/main.rs index f78b819..cb08cf5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -10,6 +10,7 @@ mod lex; #[cfg(test)] mod lex_tests; mod rules; +mod tree; mod version; use std::path::{Path, PathBuf}; @@ -23,7 +24,8 @@ commands: verify cargo fmt --all --check; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace (stops at first failure, non-zero exit) layers enforce the crate dependency layering (SPEC §3.1) - ci verify, layers, i18n --strict + tree fail when a source directory holds more than 12 loose .rs files + ci verify, layers, tree, i18n --strict ico ico OUT.ico IN.png... packs PNGs into a Windows .ico (packaging/icons.sh) version print the workspace version; `version set X.Y.Z[-pre]` changes it i18n scan ui-egui/src and apps/*/src for hardcoded user strings (SPEC §12); @@ -35,6 +37,7 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("verify") => cmd_verify(), Some("layers") => cmd_layers(), + Some("tree") => tree::run(&root()), Some("ci") => cmd_ci(), Some("i18n") => cmd_i18n(&args), Some("ico") => ico::run(&args.iter().skip(1).map(String::as_str).collect::>()), @@ -151,6 +154,8 @@ fn cmd_ci() -> Result<(), String> { println!(); cmd_layers()?; println!(); + tree::run(&root())?; + println!(); i18n::run(&[String::from("--strict")]) } diff --git a/xtask/src/tree.rs b/xtask/src/tree.rs new file mode 100644 index 0000000..2a441cc --- /dev/null +++ b/xtask/src/tree.rs @@ -0,0 +1,86 @@ +//! Source-folder hygiene: no directory under a crate's `src/` may hold more than [`MAX_LOOSE`] loose `.rs` +//! files. Group files into sub-folders by concern instead (a folder may itself sit inside a parent folder). + +use std::path::Path; + +/// Most `.rs` files one source directory may hold directly (`mod.rs` and `lib.rs` do not count). +pub const MAX_LOOSE: usize = 12; + +/// Directories (workspace-relative) still waiting for their restructure; remove each entry when it is done. +/// ROADMAP.md lists the same work under "Next up". +const PENDING: &[&str] = &[ + "crates/ui-egui/src", + "crates/ui-egui/src/whiteboard_view", + "crates/ui-egui/src/text", + "crates/ui-egui/src/app", + "crates/ui-egui/src/assistant_panel", + "crates/engine/src", +]; + +/// Source directories over the limit, as `(path, loose file count)`, skipping [`PENDING`] ones. +pub fn crowded(root: &Path) -> Vec<(String, usize)> { + let mut out = Vec::new(); + for top in ["crates", "apps"] { + let Ok(entries) = std::fs::read_dir(root.join(top)) else { continue }; + for entry in entries.flatten() { + walk(root, &entry.path().join("src"), &mut out); + } + } + out.retain(|(path, _)| !PENDING.contains(&path.as_str())); + out.sort(); + out +} + +fn walk(root: &Path, dir: &Path, out: &mut Vec<(String, usize)>) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let mut loose = 0; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(root, &path, out); + } else if path.extension().is_some_and(|ext| ext == "rs") && !path.file_name().is_some_and(|name| name == "mod.rs" || name == "lib.rs") { + loose += 1; + } + } + if loose > MAX_LOOSE { + let relative = dir.strip_prefix(root).unwrap_or(dir).to_string_lossy().replace('\\', "/"); + out.push((relative, loose)); + } +} + +/// `cargo xtask tree`: fails when a source directory holds too many loose files. +pub fn run(root: &Path) -> Result<(), String> { + let crowded = crowded(root); + if crowded.is_empty() { + println!("OK: no source directory holds more than {MAX_LOOSE} loose files ({} pending restructure).", PENDING.len()); + return Ok(()); + } + for (path, count) in &crowded { + println!(" - {path}: {count} loose files (max {MAX_LOOSE}); group them into sub-folders by concern"); + } + Err(format!("{} crowded source director{}", crowded.len(), if crowded.len() == 1 { "y" } else { "ies" })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_crowded_directory_is_reported_and_mod_rs_does_not_count() { + let root = std::env::temp_dir().join(format!("nc-tree-test-{}", std::process::id())); + let src = root.join("crates/demo/src"); + assert!(std::fs::create_dir_all(src.join("grouped")).is_ok()); + for index in 0..=MAX_LOOSE { + assert!(std::fs::write(src.join(format!("file{index}.rs")), "").is_ok()); + } + assert!(std::fs::write(src.join("lib.rs"), "").is_ok()); + assert!(std::fs::write(src.join("grouped/mod.rs"), "").is_ok()); + assert_eq!(crowded(&root), vec![("crates/demo/src".to_owned(), MAX_LOOSE + 1)]); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn the_workspace_itself_is_within_the_limit() { + assert_eq!(crowded(&crate::root()), Vec::<(String, usize)>::new()); + } +}