diff --git a/docs/organization-research-implementation.md b/docs/organization-research-implementation.md new file mode 100644 index 000000000..27f42bf6d --- /dev/null +++ b/docs/organization-research-implementation.md @@ -0,0 +1,103 @@ +# Evidence-based organization implementation + +Status: in progress. Research observations are not shipped capability evidence. + +The organization planner and execution source validator now preserve package descendants and companion-file relationships. Same-basename files with different extensions are retained, not treated as duplicate content. Execution inspects current siblings so a companion omitted from a bounded inventory cannot be silently separated. Incomplete sibling inspection refuses the individual move. + +## Remaining product work + +- Validate retained-item rendering and empty preview behavior in the actual app. The response and Organize now include package, companion and unplanned-item reasons. +- Represent content-grounded project/activity bundles, document roles, concept facets and unresolved evidence without inventing topic confidence. +- Preserve all distinct drafts; identify final versions only from authoritative evidence. +- Keep low-context transcripts unclassified and preserve source-relative audio/transcript/metadata relationships. +- Implement native coordinated iCloud bundle moves with exclusive destination creation, content-bound manifests, fresh shared/upload/conflict checks, durable receipts and verified undo. The operational Swift helper is not yet part of the application. +- Keep local move verification distinct from provider upload completion and remote-device verification; moving files contributes zero reclaimed bytes. +- Exercise the public planning, execution and undo paths against realistic synthetic fixtures; do not commit private user documents as fixtures. + +The first boundary guard is a safety prerequisite, not completion of semantic organization or the 300GiB goal. Existing filename/extension classification is not a content-based ontology implementation. + +## Validation checkpoint + +- Standalone production boundary tests: 2 passed, including a symlink alias into an application package. +- Existing frontend API wrapper tests: 10 passed. These verify command forwarding, not native move safety. +- Svelte diagnostics: 0 errors and 0 warnings; the latest Organize component also compiled without warnings. +- Rust organization tests: 25 passed, including metadata binding, companion preservation, package exclusions and retained-item reporting. The latest command-path suite passed 29 tests, including preservation of an unlisted companion, no move journal on refusal, ordinary moves and undo collision handling. +- Repository-wide format checking reports pre-existing differences outside this change; no mass formatting was applied. + +The current sibling observation is a pre-execution check, not a filesystem transaction: an uncoordinated writer can still add a companion after it. Native coordinated bundle execution and late-writer handling remain required. The preview covers a bounded inventory and explicitly does not attest whole-tree completeness. Shared/session/project-marker boundaries beyond recognized package suffixes remain part of the pending implementation, not inferred coverage from these tests. + +## Native integration findings + +The product already depends on objc2 0.6.4 and objc2-foundation 0.3.2 and calls Foundation directly for iCloud state/eviction. Reuse that boundary for moves instead of invoking a Swift interpreter on the user's machine. The installed Foundation bindings expose NSFileCoordinator's two-writing-URL accessor, ForMoving option, and willMoveTo/didMoveTo notifications. Their coordinator, presenter and block features are not currently enabled in this product. + +The accessor must use the URLs supplied by Foundation, revalidate the approved content manifest inside coordination, and perform an exclusive rename. Coordination is not evidence that an uncoordinated writer cannot modify contents. Persist a pending receipt before mutation and retain an inspection-required result on ambiguous completion; do not automatically replay a move. Existing single-file hard-link execution does not satisfy this bundle contract. + +Context7 documentation lookup returned a quota error. These findings come from the installed version's generated bindings and existing product source; they do not constitute a native runtime test. + +Destination follow-up: package destinations are excluded during planning; execution also resolves the nearest existing destination ancestor to reject aliases into packages. The updated organization suite passed 28 tests, including the destination-plan regression and native symlink fixture. This remains a pre-execution check, not an atomic filesystem guarantee. + +Probe-budget regression: a 201-item fixture with a 200-probe budget reproduced 201 executable plans (RED, expected 200). The exhausted-budget branch now withholds a plan instead of substituting empty metadata. The omitted item remains visible in the retained preview. Post-fix organization tests passed 28/28; this change does not claim semantic classification for the first 200 items. + +## Session preservation during preview + +The planner now reuses the shared agent-state guard before classification and rejects destinations in protected state. The preview explains retained session files. Execution continues to use the same protected move path supplied by PR #345; PR #346 is stacked on that branch until its protected merge. A regression case checks both source preservation before the picker and protected destination rejection. + +Validation after shared-guard integration: 29 organization tests passed with `cargo test --manifest-path src-tauri/Cargo.toml --lib organize:: --no-default-features --offline`; `npm run check` reported zero errors and warnings. The metadata-budget fixture took over 60 seconds after path resolution was added; this is a latency observation requiring investigation, not a failed test or a throughput guarantee. + +## Exact undo paths + +A Unix filename containing ` -> ` reproduced a failed undo in the public command core. New move receipts now contain separate source and destination fields; the display string is no longer parsed for new receipts. Legacy receipts remain readable, but ambiguous legacy path strings are skipped instead of guessed. The focused command regression failed before this fix; all 29 command tests passed after the fix. This change alone does not provide coordinated iCloud transactions, crash-durable receipts, or protection against replacement of a moved file before undo. + +The exact shared guard measured 458 ms for the synthetic `/home/u/Media/Image/0.png` path, versus less than 1 ms for `/downloads/0.png` and a `/tmp` path in the same process. The metadata-budget fixture now supplies an actual temporary home directory. Production protection remains unchanged; this observation does not establish production throughput. + +Follow-up validation: all 47 shared safety tests passed for structured undo receipts. The metadata-budget regression passed in 0.06 seconds with the actual temporary home, compared with the previous 91.41-second organization run. Same-volume movement now calls the existing exclusive rename primitive instead of linking then unlinking; all 47 shared safety and 29 command regressions passed after this change. Native iCloud coordination remains unfinished. + +## Native coordinated move integration + +Same-volume macOS movement now uses Foundation file coordination before the exclusive rename. The callback rejects changed URLs, rechecks agent-state protection and the source and resolved destination-parent identities, then announces the successful native move before writing its final journal outcome. Forward moves and undo share this path. Cross-volume movement is unchanged. + +This uses the installed objc2-foundation 0.3.2 bindings and block2 0.6.2 already present in the lockfile; no runtime Swift compiler or helper process is needed. Context7 was unavailable because its monthly quota was exhausted, so the exact installed bindings and [Apple's coordination reference](https://developer.apple.com/documentation/foundation/nsfilecoordinator) and [move notification reference](https://developer.apple.com/documentation/foundation/nsfilecoordinator/item(at:willmoveto:)) were inspected. + +The native safety suite passed 47 tests, and the command suite passed 29 tests including forward movement and undo. Foundation emitted sandbox-extension diagnostic messages in the test process even though the operations and preservation assertions passed; this is not evidence of signed-app sandbox entitlements. Local coordinated movement does not prove iCloud upload completion, cross-device consistency, crash-durable recovery, or exclusion of writers that do not participate in file coordination. General recursive bundle manifests and crash-durable receipts remain unfinished. + +The organizing command now supplies its existing plan validator to the shared move transaction. It runs before preparation and again inside the native accessor (or immediately before non-macOS/cross-volume mutation). A synthetic companion arriving after preflight first reproduced a missing revalidation, then passed with source and companion contents intact and no destination file. All 30 command and 47 safety tests passed after the fix. This does not exclude uncoordinated writers racing after the final validation. + +## Whole-folder capability audit + +The `organization_lineage` export carries path-free per-file metadata and cannot authorize a folder move. The initial audit found no content-bound membership manifest in `MovePlan`; the bounded pilot below now adds one. Existing orphan and developer-artifact manifests remain metadata-only cleanup guards and are not used as proof of document content preservation. Semantic grouping is still incomplete. + +The bounded pilot binds complete membership, raw relative names, file contents, and modification metadata to its supported bundles and revalidates this for movement and undo. General recursive bundles remain unsupported. It must preserve packages, project and sharing boundaries and retain incomplete or unavailable cloud content. Content/ontology grouping needs a separate evidence-backed decision: equal extensions, shared basenames, or an existing parent alone cannot establish semantic equivalence. Existing companion preservation remains a veto, not a grouping verdict. Folder movement must not be counted as reclaimed storage. + +## Existing-folder movement pilot + +The UI now previews an explicitly selected existing folder and destination parent. It does not infer a topic or ontology class. The current supported scope is a flat bundle of at most 32 local regular files totaling 8 MiB. Nested, unavailable, linked, cloud-only, recognized project, and protected package/session scopes are retained. General recursive and semantic grouping remain unfinished. + +The move plan and undo receipt now bind member names, file identities, sizes, exact modification timestamps, and content digests. The same plan validator runs inside native coordination. Movement across volumes is unavailable for these bundles. Tests have verified a decomposed-Hangul folder name, complete companion movement and undo, refusal after a new member appears, and same-size/same-mtime content drift. The first completed command run passed 31 tests and failed the unchanged live cache-cleanup assertion. After adding error diagnostics and the project-boundary regression, all 33 command tests passed. The intermittent cache assertion was not reproduced in that run; its underlying cause is not established by this result. All 47 shared safety tests also passed. The latest frontend check reported zero errors and warnings. No real user folder was moved by this pilot validation. + +### Bounded content verification beyond the initial prototype + +A reviewed four-document group totaled 826,183 bytes and exceeded the initial 512 KiB +budget. A synthetic public-core regression reproduced that refusal before the change. +The proposed implementation keeps a fixed 64 KiB read buffer and incremental BLAKE3 +state, with an 8 MiB total observation budget and the existing 32-file limit. This is +an operational bound for small document groups, not a classification threshold or a +claim of support for arbitrary folder trees. The bounded reader includes one extra +byte to detect growth; observed byte count, identity, length and modification checks +still reject changed sources. Dataless, project, package, link, collision and +cross-volume restrictions remain in force. The regression covers the observed group +size, digest equivalence, execution/undo, exact-budget acceptance and one-byte excess. +Performance and iCloud behavior for this wider budget remain unverified until measured; +local test success alone is not permission to move a private group or claim deployment. + +## Read-only bundle preview + +`cargo run --manifest-path src-tauri/Cargo.toml --bin disksage-organization-plan -- ABSOLUTE_SOURCE ABSOLUTE_TARGET_PARENT` +prints the same bounded plan used by the desktop preview. It does not create the +target or move files, and has no execution option. Keep its output private: it +contains original paths, filenames and content fingerprints. Cloud-only members +are refused by the shared planner; the command does not request downloads. + +This preview proves neither the proposed grouping's meaning nor cloud synchronization. +Those observations remain separate prerequisites for an actual move. The command's +regression checks source preservation, absent destination creation, collision refusal, +and the absence of an execution option. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 5ecd46866..358de083f 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -679,3 +679,67 @@ At each scheduled or operator loop, update this file only with new dated evidenc pipe leak that could starve the independent `ps` probe and report a false active-use timeout. The focused Rust test passed 3/3. The same patch is present on stacked PR heads `a0fa7bc` (#247) and `741ab30` (#246); hosted checks are rerunning and protected merge/review is still pending. + +## Organization evidence gap — source audit at ae41fc9e + +Status: open, not shipped. Source revision: `ae41fc9e3b37bc7c9a62a3c46e4ee421f2f360dc` (PR #346). + +The organization preview retains session, package, companion and project-boundary cases, +and deliberately reports that the whole tree is unverified. These safeguards do not establish +why a document belongs to a semantic group. The inspected move-plan record carries a class, +source metadata, production-time lineage and an optional bundle manifest. It has no explicit +record for the content-supported classification rationale, concept-scheme release, or uncertainty +of the proposed semantic relationship. Production-time confidence must not be interpreted as +classification confidence. + +Acceptance remains open: bind a classification decision to the reviewed source evidence and +concept version, expose its uncertainty before execution, and preserve that decision through +execution and undo receipts. Verify the complete command path with synthetic boundary cases +without publishing private document text. Reuse the existing plan and journal boundaries; +do not introduce an independent classification authority or infer deletion eligibility from +semantic similarity. A source fingerprint or successful safety test alone cannot close this gap. + +Research acceptance also remains open: compare existing, topic, project and hybrid structures +using independent groups and explicit retrieval tasks. Existing small content reviews support +counterexamples only; no user retrieval-time improvement has been measured. RankWeave v0.18.0 +is a possible ranked-retrieval evaluation owner, not evidence of folder-navigation effectiveness +or a deployed integration. + +### Execution and undo trace for the evidence gap + +At the same audited revision, `commands::execute_moves_inner` passes source/destination, +a source-validation callback and the optional bundle manifest into `safety::move_file_checked`. +`safety::do_move` writes pending/result entries with structured paths and the bundle, but does +not persist the plan's class or production-time lineage. `undo_last_moves_inner` reconstructs +an inverse from journal paths and bundle data. Adding a decision field only to `MovePlan` +would therefore leave both the durable receipt and inverse operation incomplete. + +The repair must carry the reviewed decision through the shared move boundary into the existing +pending/result journal entries, and link an undo to that original decision without reclassifying +it. Legacy entries must remain explicitly evidence-unavailable. Before implementation, verify +all journal constructors and public command serialization; after implementation, exercise plan +serialization, execution journal round-trip and undo preservation through the public core. +This trace is evidence of a missing contract, not a claim that the repair has been implemented. + +### Multi-topic document evidence — exploratory observation, 2026-09-08 + +Two private conversation exports (21,803 bytes combined) were reviewed after native +non-shared/upload-complete/no-conflict checks. The dialogue continues a career discussion +across both exports; the second changes to statistical analysis near its end and explicitly +proposes restarting the call to separate the summary. This contradicts treating one whole +file as exactly one topic. It does not establish project ownership, transcript accuracy, +app-link independence, or a correct destination. Both originals stayed in place. Size and +modification time were unchanged after reading; native post-read observation reported both +uploaded with no conflicts. No other-device check or retrieval experiment was performed. + +Acceptance must preserve the original event/document group while allowing multiple topic +associations tied to reviewed spans. Generated summaries and speakers' hypotheses must +remain attributed assertions, not domain facts. Topic associations must not authorize +splitting or deleting the source. A synthetic regression should cover a mid-document topic +change with an unresolved app reference, keeping physical movement on hold. Private text, +personal identifiers, and private source paths must never become public test fixtures. + +The proposed producer-source patch in PR #346 is only a prerequisite: it identifies the +source of a suggestion and carries that label into move/undo receipts. It does not implement +span evidence, a released concept mapping, independent evaluation, or app-link verification. +This gap remains open regardless of that patch's test outcome. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bcf4c1e71..537956f9c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -979,6 +979,7 @@ version = "0.1.0" dependencies = [ "base64 0.23.1", "blake3", + "block2", "calamine", "csv", "embed_plist", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 15cc5b18c..854134742 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -186,7 +186,8 @@ winapi-util = "0.1.11" [target.'cfg(target_os = "macos")'.dependencies] embed_plist = "1.2.2" objc2 = "0.6.4" -objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSError", "NSFileManager", "NSObject", "NSString", "NSURL", "NSValue"] } +block2 = "0.6.2" +objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "block2", "NSError", "NSFileCoordinator", "NSFilePresenter", "NSFileManager", "NSObject", "NSString", "NSURL", "NSValue"] } plist = "1" [features] diff --git a/src-tauri/src/bin/disksage-organization-plan.rs b/src-tauri/src/bin/disksage-organization-plan.rs new file mode 100644 index 000000000..f3aa41421 --- /dev/null +++ b/src-tauri/src/bin/disksage-organization-plan.rs @@ -0,0 +1,60 @@ +//! Read-only bundle preview. No move, download, or execution option is exposed. + +use std::{ffi::OsString, io::Write, path::Path}; + +const USAGE: &str = "usage: disksage-organization-plan ABSOLUTE_SOURCE ABSOLUTE_TARGET_PARENT\nPreview only; content meaning and cloud synchronization are not verified."; + +fn write_preview(args: &[OsString], output: &mut impl Write) -> Result<(), String> { + if args.len() == 1 && matches!(args[0].to_str(), Some("--help" | "-h")) { + return writeln!(output, "{USAGE}").map_err(|error| error.to_string()); + } + if args.len() != 2 || args.iter().any(|value| value.to_str().is_none()) { + return Err(USAGE.into()); + } + let plan = disksage_lib::plan_organization_bundle(Path::new(&args[0]), Path::new(&args[1]))?; + serde_json::to_writer_pretty(&mut *output, &plan).map_err(|error| error.to_string())?; + writeln!(output).map_err(|error| error.to_string()) +} + +fn main() { + if let Err(error) = write_preview( + &std::env::args_os().skip(1).collect::>(), + &mut std::io::stdout().lock(), + ) { + eprintln!("{error}"); + std::process::exit(2); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preview_preserves_source_and_refuses_destination_collision() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().canonicalize().unwrap(); + let source = root.join("document_group"); + let target = root.join("reviewed_groups"); + std::fs::create_dir(&source).unwrap(); + let file_path = source.join("original draft.txt"); + std::fs::write(&file_path, b"distinct original content").unwrap(); + let args = [source.clone().into_os_string(), target.clone().into_os_string()]; + let mut output = Vec::new(); + write_preview(&args, &mut output).unwrap(); + let preview: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(preview["bundle"]["files"][0]["name"], "original draft.txt"); + assert_eq!(preview["classification_source"], serde_json::Value::Null); + assert!(!target.exists()); + assert_eq!(std::fs::read(&file_path).unwrap(), b"distinct original content"); + std::fs::create_dir_all(target.join("document_group")).unwrap(); + output.clear(); + assert!(write_preview(&args, &mut output).is_err()); + assert!(output.is_empty()); + assert_eq!(std::fs::read(&file_path).unwrap(), b"distinct original content"); + assert!(write_preview(&[], &mut output).is_err()); + assert!(write_preview(&["--execute".into()], &mut output).is_err()); + write_preview(&["--help".into()], &mut output).unwrap(); + assert!(String::from_utf8(output).unwrap().contains("Preview only")); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4265d7751..be9a8822e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -185,11 +185,13 @@ pub fn clean_dev_artifacts_inner( .collect() } -/// 저널의 move 경로 필드 "src -> dst"를 분리 (순수 함수 — 테스트 대상). 구분자 없으면 None. +/// Read unambiguous legacy move paths. New entries use structured paths. pub fn parse_move_entry(path_field: &str) -> Option<(String, String)> { - path_field - .split_once(" -> ") - .map(|(s, d)| (s.to_string(), d.to_string())) + let (source, destination) = path_field.split_once(" -> ")?; + if source.is_empty() || destination.is_empty() || destination.contains(" -> ") { + return None; + } + Some((source.to_owned(), destination.to_owned())) } /// MovePlan을 safety::move_file로 실행하는 순수 코어 — 항목별 결과, 하나 실패해도 나머지는 진행 (M2와 동일 원칙) @@ -201,10 +203,10 @@ pub fn execute_moves_inner( plans .iter() .map(|p| { - match organize::validate_move_source(p).and_then(|_| { - safety::move_file(Path::new(&p.src), Path::new(&p.dst), journal_path, now_ms) - .map_err(|error| error.to_string()) - }) { + match safety::move_file_checked( + Path::new(&p.src), Path::new(&p.dst), journal_path, now_ms, + &|| organize::validate_move_source(p).map_err(safety::SafetyError::Validation), p.bundle.as_ref(), p.classification_source.as_deref(), + ) { Ok(()) => CleanResult { path: p.src.clone(), ok: true, @@ -230,16 +232,27 @@ pub fn undo_last_moves_inner(limit: usize, journal_path: &Path, now_ms: u64) -> .iter() .filter(|e| e.op == "move" && e.outcome == "ok") .take(limit) - .filter_map(|e| parse_move_entry(&e.path)) - .map(|(src, dst)| { - match safety::move_file(Path::new(&dst), Path::new(&src), journal_path, now_ms) { + .filter_map(|e| { + e.move_paths.as_ref().map(|paths| (paths.source.clone(), paths.destination.clone(), paths.bundle.clone(), paths.classification_source.clone())) + .or_else(|| parse_move_entry(&e.path).map(|(src, dst)| (src.into(), dst.into(), None, None))) + }) + .map(|(src, dst, bundle, classification_source)| { + let inverse = organize::MovePlan { src: dst.to_string_lossy().into_owned(), + dst: src.to_string_lossy().into_owned(), bundle, ..Default::default() }; + let moved = safety::move_file_checked(&dst, &src, journal_path, now_ms, + &|| if inverse.bundle.is_some() { + organize::validate_move_source(&inverse).map_err(safety::SafetyError::Validation) + } else { + Ok(()) + }, inverse.bundle.as_ref(), classification_source.as_deref()); + match moved { Ok(()) => CleanResult { - path: src, + path: src.to_string_lossy().into_owned(), ok: true, error: String::new(), }, Err(e) => CleanResult { - path: src, + path: src.to_string_lossy().into_owned(), ok: false, error: e.to_string(), }, @@ -2935,7 +2948,7 @@ pub fn plan_organize( root: String, app: AppHandle, state: State, -) -> Result, String> { +) -> Result { let onto = load_ontology_from(&bundled_ontology_ttl(&app)?)?; let rules = crate::userrules::parse_rules(&user_rules_json(&app))?; let files = dupes::collect_files_bounded(Path::new(&root), 10_000, Duration::from_secs(10))?; @@ -2965,7 +2978,7 @@ pub fn plan_organize( } crate::llm::pick_class(engine, &meta, cands) }; - return Ok(organize::plan_moves_with_metadata( + return Ok(organize::organization_preview(&files, organize::plan_moves_with_metadata( &files, &onto, &home, @@ -2973,11 +2986,11 @@ pub fn plan_organize( &rules, &pick, &organize::lineage_metadata_for_path, - )); + ))); } } } - Ok(organize::plan_moves_with_metadata( + Ok(organize::organization_preview(&files, organize::plan_moves_with_metadata( &files, &onto, &home, @@ -2985,7 +2998,13 @@ pub fn plan_organize( &rules, &|_, _| None, &organize::lineage_metadata_for_path, - )) + ))) +} + +#[cfg(not(coverage))] +#[tauri::command(async)] +pub fn plan_bundle_organize(root: String, target_parent: String) -> Result { + organize::organization_bundle::plan(Path::new(&root), Path::new(&target_parent)) } #[cfg(not(coverage))] @@ -3503,6 +3522,10 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . #[test] fn parse_move_entry_splits_valid_entry() { + let legacy: safety::JournalEntry = serde_json::from_str( + r#"{"ts_ms":1,"op":"move","path":"/a/b -> /c/d","bytes":1,"outcome":"ok"}"#, + ).unwrap(); + assert!(legacy.move_paths.is_none()); assert_eq!( parse_move_entry("/a/b -> /c/d"), Some(("/a/b".to_string(), "/c/d".to_string())) @@ -3512,6 +3535,8 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . #[test] fn parse_move_entry_malformed_is_none() { assert_eq!(parse_move_entry("no arrow here"), None); + assert_eq!(parse_move_entry("/a/draft -> revised.bin -> /b/draft -> revised.bin"), None); + assert_eq!(parse_move_entry(" -> /b"), None); } #[test] @@ -3613,7 +3638,8 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . } let results = clean_regenerable_caches_inner(&bases, &tmp.path().join("journal.jsonl"), 7); assert_eq!(results.len(), 6); - assert!(results.iter().all(|result| result.ok)); + assert!(results.iter().all(|result| result.ok), "{:?}", + results.iter().map(|result| (&result.path, &result.error)).collect::>()); } #[test] @@ -3644,6 +3670,153 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . assert!(artifact.join("payload.bin").exists()); } + #[test] + fn execute_moves_preserves_late_relationship_markers_without_journal() { + for (marker, expected_error) in [ + ("recording.tmk", "organize-companion-bundle-required"), + (".git", "프로젝트 내부 자료는 기존 관계를 보존하기 위해 따로 옮기지 않습니다."), + ] { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("recording.wav"); + let companion = tmp.path().join(marker); + let destination = tmp.path().join("organized/recording.wav"); + let journal = tmp.path().join("operations.jsonl"); + std::fs::write(&source, b"audio").unwrap(); + let plans = vec![organize::MovePlan { + src: source.to_string_lossy().into_owned(), + dst: destination.to_string_lossy().into_owned(), + ..Default::default() + }]; + // A relationship marker arriving after the plan must retain the original. + std::fs::write(&companion, b"markers").unwrap(); + let results = execute_moves_inner(&plans, &journal, 1); + assert_eq!(results.len(), 1); + assert!(!results[0].ok); + assert_eq!(results[0].error, expected_error); + assert_eq!(std::fs::read(source).unwrap(), b"audio"); + assert_eq!(std::fs::read(companion).unwrap(), b"markers"); + assert!(!destination.exists()); + assert!(!journal.exists()); + } + } + + #[test] + fn bundle_move_and_undo_preserve_members_and_reject_new_members() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("\u{1106}\u{116e}\u{11ab}\u{1109}\u{1165} bundle"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("recording.wav"), b"original audio").unwrap(); + std::fs::write(source.join("recording.json"), b"original metadata").unwrap(); + let target = tmp.path().join("organized"); + let journal = tmp.path().join("journal.jsonl"); + let plan = organize::organization_bundle::plan(&source, &target).unwrap(); + let destination = Path::new(&plan.dst); + assert_eq!(plan.bundle.as_ref().unwrap().files.len(), 2); + assert!(execute_moves_inner(std::slice::from_ref(&plan), &journal, 1)[0].ok); + assert!(!source.exists()); + assert!(undo_last_moves_inner(1, &journal, 2)[0].ok); + assert_eq!(std::fs::read(source.join("recording.wav")).unwrap(), b"original audio"); + assert_eq!(std::fs::read(source.join("recording.json")).unwrap(), b"original metadata"); + assert!(!destination.exists()); + assert!(execute_moves_inner(std::slice::from_ref(&plan), &journal, 3)[0].ok); + std::fs::write(destination.join("new note.txt"), b"keep this too").unwrap(); + assert!(!undo_last_moves_inner(1, &journal, 4)[0].ok); + assert!(!source.exists()); + assert_eq!(std::fs::read(destination.join("new note.txt")).unwrap(), b"keep this too"); + } + + #[test] + fn bundle_streamed_document_group_roundtrips_and_retains_over_budget() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("document group"); + std::fs::create_dir(&source).unwrap(); + let sizes = [258446usize, 186177, 242389, 139171]; + for (index, size) in sizes.iter().enumerate() { + std::fs::write(source.join(format!("document_{index}.bin")), vec![index as u8; *size]).unwrap(); + } + let target = tmp.path().join("organized"); + let journal = tmp.path().join("journal.jsonl"); + let plan = organize::organization_bundle::plan(&source, &target).unwrap(); + for member in &plan.bundle.as_ref().unwrap().files { + assert_eq!(member.content_blake3, blake3::hash(&std::fs::read(source.join(&member.name)).unwrap()).to_hex().to_string()); + } + assert!(execute_moves_inner(&[plan], &journal, 1)[0].ok); + assert!(undo_last_moves_inner(1, &journal, 2)[0].ok); + for (index, size) in sizes.iter().enumerate() { + assert_eq!(std::fs::read(source.join(format!("document_{index}.bin"))).unwrap(), vec![index as u8; *size]); + } + let extra = source.join("over_budget.bin"); + let remaining = 8 * 1024 * 1024 - sizes.iter().sum::() as u64; + let extra_file = std::fs::File::create(&extra).unwrap(); + extra_file.set_len(remaining).unwrap(); + assert!(organize::organization_bundle::plan(&source, &target).is_ok()); + extra_file.set_len(remaining + 1).unwrap(); + assert!(organize::organization_bundle::plan(&source, &target).is_err()); + assert_eq!(std::fs::metadata(extra).unwrap().len(), remaining + 1); + assert!(source.exists()); + } + + #[test] + fn bundle_preview_retains_nested_folders_and_project_ancestors() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("notes"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("draft.txt"), b"keep").unwrap(); + let target = tmp.path().join("organized"); + std::fs::create_dir(source.join("nested")).unwrap(); + assert!(organize::organization_bundle::plan(&source, &target).is_err()); + std::fs::remove_dir(source.join("nested")).unwrap(); + std::fs::create_dir(tmp.path().join(".git")).unwrap(); + assert!(organize::organization_bundle::plan(&source, &target).is_err()); + assert_eq!(std::fs::read(source.join("draft.txt")).unwrap(), b"keep"); + assert!(!target.exists()); + } + + #[test] + fn bundle_move_rejects_same_size_content_drift_before_mutation() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("notes"); + std::fs::create_dir(&source).unwrap(); + std::fs::write(source.join("draft.txt"), b"first").unwrap(); + let plan = organize::organization_bundle::plan(&source, &tmp.path().join("organized")).unwrap(); + let modified = std::fs::metadata(source.join("draft.txt")).unwrap().modified().unwrap(); + std::fs::write(source.join("draft.txt"), b"other").unwrap(); + std::fs::OpenOptions::new().write(true).open(source.join("draft.txt")).unwrap() + .set_times(std::fs::FileTimes::new().set_modified(modified)).unwrap(); + let journal = tmp.path().join("journal.jsonl"); + assert!(!execute_moves_inner(&[plan], &journal, 1)[0].ok); + assert_eq!(std::fs::read(source.join("draft.txt")).unwrap(), b"other"); + assert!(!journal.exists()); + } + + #[test] + fn checked_move_retains_companion_arriving_after_preflight() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("recording.wav"); + let companion = tmp.path().join("recording.json"); + let destination = tmp.path().join("dest/recording.wav"); + let journal = tmp.path().join("journal.jsonl"); + std::fs::write(&source, b"original").unwrap(); + let plan = organize::MovePlan { + src: source.to_string_lossy().into_owned(), + dst: destination.to_string_lossy().into_owned(), + ..Default::default() + }; + let checks = std::cell::Cell::new(0); + let result = safety::move_file_checked(&source, &destination, &journal, 1, &|| { + checks.set(checks.get() + 1); + if checks.get() == 2 { + std::fs::write(&companion, b"metadata").unwrap(); + } + organize::validate_move_source(&plan).map_err(safety::SafetyError::Validation) + }, None, None); + assert_eq!(checks.get(), 2); + assert!(result.is_err()); + assert_eq!(std::fs::read(&source).unwrap(), b"original"); + assert_eq!(std::fs::read(&companion).unwrap(), b"metadata"); + assert!(!destination.exists()); + } + #[test] fn execute_moves_inner_reports_per_item_and_isolates_failures() { let tmp = tempfile::tempdir().unwrap(); @@ -3677,13 +3850,16 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . fn undo_last_moves_inner_reverses_recent_moves_newest_first() { let tmp = tempfile::tempdir().unwrap(); let jp = tmp.path().join("j.jsonl"); - let a = tmp.path().join("a.bin"); + // Windows forbids > in filenames; the legacy parser regression runs on every OS. + let name = if cfg!(unix) { "draft -> revised.bin" } else { "draft revised.bin" }; + let a = tmp.path().join(name); std::fs::write(&a, vec![2u8; 8]).unwrap(); - let a_moved = tmp.path().join("dest").join("a.bin"); + let a_moved = tmp.path().join("dest").join(name); let plans = vec![organize::MovePlan { src: a.to_string_lossy().into(), dst: a_moved.to_string_lossy().into(), class_id: "x".into(), + classification_source: Some("user_rule".into()), ..Default::default() }]; execute_moves_inner(&plans, &jp, 5); @@ -3692,8 +3868,13 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko . let undone = undo_last_moves_inner(10, &jp, 6); assert_eq!(undone.len(), 1); assert!(undone[0].ok); - assert!(a.exists()); + assert_eq!(std::fs::read(&a).unwrap(), vec![2u8; 8]); assert!(!a_moved.exists()); + let entries = safety::journal_recent(&jp, usize::MAX); + let moves: Vec<_> = entries.iter().filter(|e| e.op == "move").collect(); + assert_eq!(moves.len(), 4); + assert!(moves.iter().all(|entry| entry.move_paths.as_ref().unwrap() + .classification_source.as_deref() == Some("user_rule"))); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad9481876..78fb00abd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -34,6 +34,8 @@ mod ontology; mod inventory; #[cfg_attr(coverage, allow(dead_code))] mod organize; +/// Read-only, bounded preview using the same bundle planner as the desktop application. +pub use organize::organization_bundle::plan as plan_organization_bundle; #[cfg_attr(coverage, allow(dead_code))] mod llm; #[cfg_attr(coverage, allow(dead_code))] @@ -125,6 +127,7 @@ pub fn run() { commands::disk_inventory, commands::ontology_coherence, commands::plan_organize, + commands::plan_bundle_organize, commands::export_organization_lineage, commands::user_rules, commands::execute_moves, diff --git a/src-tauri/src/organization_boundary.rs b/src-tauri/src/organization_boundary.rs new file mode 100644 index 000000000..d59cdd466 --- /dev/null +++ b/src-tauri/src/organization_boundary.rs @@ -0,0 +1,216 @@ +//! Preserve package and companion-file relationships before individual organization moves. +use std::path::Path; + +/// A package descendant must not become an independently movable document. +pub fn package_ancestor(path: &Path) -> bool { + path.ancestors().any(|part| { + part.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| { + [ + "app", + "bundle", + "framework", + "photoslibrary", + "xcodeproj", + "xcworkspace", + ] + .iter() + .any(|suffix| value.eq_ignore_ascii_case(suffix)) + }) + }) +} + +/// Retain descendants of recognized projects, including worktree marker files. +pub fn validate_project_ancestors(directory: &Path) -> Result<(), String> { + let resolved = std::fs::canonicalize(directory) + .map_err(|_| "프로젝트 경계를 확인할 수 없어 이동을 보류합니다.")?; + for ancestor in resolved.ancestors() { + for marker in [ + ".git", + ".hg", + ".svn", + "Cargo.toml", + "package.json", + "pyproject.toml", + "go.mod", + "CMakeLists.txt", + "wscript", + "SConstruct", + "configure.ac", + ] { + match std::fs::symlink_metadata(ancestor.join(marker)) { + Ok(_) => { + return Err( + "프로젝트 내부 자료는 기존 관계를 보존하기 위해 따로 옮기지 않습니다." + .into(), + ) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("프로젝트 경계를 확인할 수 없어 이동을 보류합니다.".into()), + } + } + } + Ok(()) +} + +/// Shared basename is only a preservation hint, never evidence of duplicate content. +pub fn companion_paths(left: &Path, right: &Path) -> bool { + left != right + && left.parent() == right.parent() + && left.file_stem().is_some() + && left.file_stem() == right.file_stem() + && left.extension() != right.extension() +} + +/// Reject destinations inside packages, including aliases through an existing ancestor. +pub fn validate_destination(path: &Path) -> Result<(), String> { + if package_ancestor(path) { + return Err("organize-destination-package-boundary".into()); + } + for ancestor in path.ancestors().skip(1) { + match std::fs::symlink_metadata(ancestor) { + Ok(_) => { + let resolved = std::fs::canonicalize(ancestor) + .map_err(|_| "organize-destination-unavailable")?; + if package_ancestor(&resolved) { + return Err("organize-destination-package-boundary".into()); + } + return Ok(()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err("organize-destination-unavailable".into()), + } + } + Err("organize-destination-unavailable".into()) +} + +/// Recheck siblings at execution: a bounded scan snapshot may omit a companion. +/// An unreadable directory cannot establish that moving one member is safe. +pub fn validate_individual_move(path: &Path) -> Result<(), String> { + if package_ancestor(path) { + return Err("organize-package-boundary".into()); + } + let resolved = std::fs::canonicalize(path).map_err(|_| "organize-source-unavailable")?; + if package_ancestor(&resolved) { + return Err("organize-package-boundary".into()); + } + let parent = path.parent().ok_or("organize-parent-unavailable")?; + validate_project_ancestors(resolved.parent().ok_or("organize-parent-unavailable")?)?; + let siblings = std::fs::read_dir(parent).map_err(|_| "organize-parent-unavailable")?; + for (index, sibling) in siblings.enumerate() { + // ponytail: refuse oversized sibling sets; a bundle-aware planner can handle them later. + if index >= 10_000 { + return Err("organize-sibling-scope-incomplete".into()); + } + let sibling = sibling.map_err(|_| "organize-sibling-unavailable")?; + if companion_paths(path, &sibling.path()) { + return Err("organize-companion-bundle-required".into()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn late_project_marker_retains_individual_document() { + let root = std::env::temp_dir().join(format!("disksage-project-{}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + let nested = root.join("documents"); + std::fs::create_dir(&nested).unwrap(); + let file = nested.join("note.txt"); + std::fs::write(&file, b"keep project relationships").unwrap(); + assert!(validate_individual_move(&file).is_ok()); + let marker = root.join(".git"); + std::fs::write(&marker, b"gitdir: elsewhere").unwrap(); + let result = validate_individual_move(&file); + #[cfg(unix)] + { + let alias = + std::env::temp_dir().join(format!("disksage-project-alias-{}", std::process::id())); + std::os::unix::fs::symlink(&nested, &alias).unwrap(); + let aliased = validate_individual_move(&alias.join("note.txt")); + std::fs::remove_file(&alias).unwrap(); + assert!(aliased.is_err()); + } + std::fs::remove_file(&marker).unwrap(); + std::fs::remove_file(&file).unwrap(); + std::fs::remove_dir(&nested).unwrap(); + std::fs::remove_dir(&root).unwrap(); + assert!( + result.is_err(), + "a late project marker must retain the document" + ); + } + + #[cfg(unix)] + #[test] + fn package_alias_cannot_bypass_preservation() { + let root = + std::env::temp_dir().join(format!("disksage-package-alias-{}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + let package = root.join("Editor.app"); + std::fs::create_dir(&package).unwrap(); + let file = package.join("document.txt"); + std::fs::write(&file, b"retain").unwrap(); + let alias = root.join("ordinary-folder"); + std::os::unix::fs::symlink(&package, &alias).unwrap(); + assert_eq!( + validate_individual_move(&alias.join("document.txt")).unwrap_err(), + "organize-package-boundary" + ); + assert_eq!( + validate_destination(&alias.join("new/sub/document.txt")).unwrap_err(), + "organize-destination-package-boundary" + ); + assert!(validate_destination(&root.join("ordinary/new/document.txt")).is_ok()); + assert_eq!(std::fs::read(&file).unwrap(), b"retain"); + std::fs::remove_file(alias).unwrap(); + std::fs::remove_file(file).unwrap(); + std::fs::remove_dir(package).unwrap(); + std::fs::remove_dir(root).unwrap(); + } + + #[test] + fn package_descendants_and_companion_roles_are_preserved() { + assert!(package_ancestor(Path::new( + "/a/Editor.APP/Contents/document.txt" + ))); + assert!(!package_ancestor(Path::new( + "/a/Editor.app-notes/document.txt" + ))); + assert!(companion_paths( + Path::new("/a/recording.wav"), + Path::new("/a/recording.tmk") + )); + assert!(companion_paths( + Path::new("/a/transcript.json"), + Path::new("/a/transcript.txt") + )); + assert!(!companion_paths( + Path::new("/a/transcript.txt"), + Path::new("/b/transcript.json") + )); + assert!(!companion_paths( + Path::new("/a/transcript.txt"), + Path::new("/a/transcript.txt") + )); + let root = std::env::temp_dir().join(format!("disksage-bundle-{}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + let original = root.join("recording.wav"); + std::fs::write(&original, b"preserve original").unwrap(); + assert!(validate_individual_move(&original).is_ok()); + let companion = root.join("recording.tmk"); + std::fs::write(&companion, b"preserve markers").unwrap(); + assert_eq!( + validate_individual_move(&original).unwrap_err(), + "organize-companion-bundle-required" + ); + assert_eq!(std::fs::read(&original).unwrap(), b"preserve original"); + std::fs::remove_file(companion).unwrap(); + std::fs::remove_file(original).unwrap(); + std::fs::remove_dir(root).unwrap(); + } +} diff --git a/src-tauri/src/organization_bundle.rs b/src-tauri/src/organization_bundle.rs new file mode 100644 index 000000000..9e80058cd --- /dev/null +++ b/src-tauri/src/organization_bundle.rs @@ -0,0 +1,194 @@ +//! Content-bound movement of an existing small document bundle, without classifying its topic. +use std::{fs, io::Read, path::Path, time::UNIX_EPOCH}; + +use super::{organization_boundary, MovePlan}; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BundleManifest { + pub root_object_id: String, + pub files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BundleFile { + pub name: String, + pub object_id: String, + pub bytes: u64, + pub modified_ns: String, + pub content_blake3: String, +} + +// ponytail: flat, 32-file/8-MiB bundles; larger or recursive groups need separate I/O-budget validation. +const MAX_FILES: usize = 32; +const MAX_BYTES: u64 = 8 * 1024 * 1024; + +fn unavailable(_: impl std::fmt::Display) -> String { + "폴더 구성이나 파일 상태를 확인하지 못해 현재 위치에 보존합니다.".into() +} + +pub fn observe(source: &Path) -> Result { + let root = fs::symlink_metadata(source).map_err(unavailable)?; + if !source.is_absolute() + || !root.is_dir() + || root.file_type().is_symlink() + || crate::safety::agent_state_guard::is_agent_state(source) + || organization_boundary::package_ancestor(source) + || organization_boundary::package_ancestor(&fs::canonicalize(source).map_err(unavailable)?) + { + return Err("보호 대상이나 일반 폴더가 아닌 항목은 묶음으로 옮기지 않습니다.".into()); + } + organization_boundary::validate_project_ancestors(source)?; + let root_object_id = crate::safety::filesystem_object_id(source).map_err(unavailable)?; + let mut files = Vec::new(); + let mut total = 0u64; + for entry in fs::read_dir(source).map_err(unavailable)? { + let entry = entry.map_err(unavailable)?; + let path = entry.path(); + let name = entry + .file_name() + .into_string() + .map_err(|_| unavailable("name"))?; + if files.len() >= MAX_FILES + || [ + "Cargo.toml", + "package.json", + "pyproject.toml", + "go.mod", + "CMakeLists.txt", + "wscript", + "SConstruct", + "configure.ac", + ] + .contains(&name.as_str()) + { + return Err("지원 범위를 넘거나 프로젝트 경계가 있는 폴더는 그대로 보존합니다.".into()); + } + let before = fs::symlink_metadata(&path).map_err(unavailable)?; + if !before.is_file() + || before.file_type().is_symlink() + || crate::cloud::metadata_is_dataless(&before) + || crate::safety::agent_state_guard::is_agent_state(&path) + { + return Err( + "하위 폴더·연결 파일·클라우드 전용 파일이 있어 묶음 이동을 보류합니다.".into(), + ); + } + let object_id = crate::safety::filesystem_object_id(&path).map_err(unavailable)?; + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(0x0020_0000); // FILE_FLAG_OPEN_REPARSE_POINT + } + let file = options.open(&path).map_err(unavailable)?; + let opened = file.metadata().map_err(unavailable)?; + if !opened.is_file() + || crate::cloud::metadata_is_dataless(&opened) + || opened.len() > MAX_BYTES.saturating_sub(total) + { + return Err("로컬 내용 확인 범위를 넘어 묶음 이동을 보류합니다.".into()); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if opened.file_attributes() & 0x400 != 0 { + return Err(unavailable("reparse point")); + } + } + #[cfg(unix)] + if crate::safety::object_id_from_metadata(&opened).as_ref() != Some(&object_id) { + return Err(unavailable("file replaced")); + } + let modified = opened.modified().map_err(unavailable)?; + let mut reader = (&file).take(MAX_BYTES.saturating_sub(total) + 1); + let mut buffer = [0u8; 64 * 1024]; + let mut hasher = blake3::Hasher::new(); + let mut bytes_read = 0u64; + loop { + let count = match reader.read(&mut buffer) { + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + result => result.map_err(unavailable)?, + }; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + bytes_read += count as u64; + } + let after = file.metadata().map_err(unavailable)?; + if bytes_read != opened.len() + || after.len() != opened.len() + || after.modified().map_err(unavailable)? != modified + || crate::safety::filesystem_object_id(&path).map_err(unavailable)? != object_id + { + return Err("확인 중 파일이 바뀌어 묶음 이동을 보류합니다.".into()); + } + total += bytes_read; + files.push(BundleFile { + name, + object_id, + bytes: opened.len(), + modified_ns: modified + .duration_since(UNIX_EPOCH) + .map_err(unavailable)? + .as_nanos() + .to_string(), + content_blake3: hasher.finalize().to_hex().to_string(), + }); + } + if files.is_empty() + || fs::metadata(source) + .map_err(unavailable)? + .modified() + .map_err(unavailable)? + != root.modified().map_err(unavailable)? + || crate::safety::filesystem_object_id(source).map_err(unavailable)? != root_object_id + { + return Err("비어 있거나 확인 중 구성이 바뀐 폴더는 보존합니다.".into()); + } + files.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(BundleManifest { + root_object_id, + files, + }) +} + +pub fn plan(source: &Path, target_parent: &Path) -> Result { + if !target_parent.is_absolute() || target_parent.starts_with(source) { + return Err("원본 폴더 바깥의 절대 경로를 대상으로 지정하세요.".into()); + } + let destination = target_parent.join(source.file_name().ok_or_else(|| unavailable("root"))?); + match fs::symlink_metadata(&destination) { + Ok(_) => return Err("대상 위치에 같은 이름이 있어 묶음 이동을 보류합니다.".into()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(unavailable(error)), + } + let canonical_source = fs::canonicalize(source).map_err(unavailable)?; + for ancestor in target_parent.ancestors() { + if let Ok(resolved) = fs::canonicalize(ancestor) { + if resolved.starts_with(&canonical_source) { + return Err("원본 폴더 안으로는 묶음을 옮길 수 없습니다.".into()); + } + break; + } + } + organization_boundary::validate_destination(&destination)?; + let bundle = observe(source)?; + Ok(MovePlan { + src: source.to_str().ok_or_else(|| unavailable("source"))?.into(), + dst: destination + .to_str() + .ok_or_else(|| unavailable("destination"))? + .into(), + bundle: Some(bundle), + ..Default::default() + }) +} diff --git a/src-tauri/src/organization_lineage.rs b/src-tauri/src/organization_lineage.rs index d410f86fa..3a93e51e4 100644 --- a/src-tauri/src/organization_lineage.rs +++ b/src-tauri/src/organization_lineage.rs @@ -157,8 +157,10 @@ mod tests { src: "/private/source/secret.mov".into(), dst: "/Users/example/Media/Media/secret.mov".into(), class_id: "https://disksage.app/ontology#Media".into(), + classification_source: None, source_size: Some(42), source_mtime_ms: Some(123), + bundle: None, lineage: LineageMetadata { production_time_ms: Some(456), production_time_source: Some("embedded:exiftool:MediaCreateDate".into()), diff --git a/src-tauri/src/organize.rs b/src-tauri/src/organize.rs index fe7b0e638..cb557398e 100644 --- a/src-tauri/src/organize.rs +++ b/src-tauri/src/organize.rs @@ -1,3 +1,8 @@ +#[path = "organization_boundary.rs"] +mod organization_boundary; +#[path = "organization_bundle.rs"] +pub mod organization_bundle; + use std::path::{Component, Path, PathBuf}; use crate::dupes::FileEntry; @@ -21,12 +26,73 @@ pub struct MovePlan { pub src: String, pub dst: String, pub class_id: String, + /// Producer provenance only; this does not attest semantic correctness. + #[serde(default)] + pub classification_source: Option, #[serde(default)] pub source_size: Option, #[serde(default)] pub source_mtime_ms: Option, #[serde(default)] pub lineage: LineageMetadata, + #[serde(default)] + pub bundle: Option, +} + +/// Preview preserves unplanned items explicitly; omission is not a deletion recommendation. +#[derive(Debug, serde::Serialize)] +pub struct OrganizationPreview { + /// The bounded collector cannot attest that it visited the entire requested tree. + pub whole_tree_verified: bool, + pub observed_file_count: usize, + pub moves: Vec, + pub retained: Vec, +} + +#[derive(Debug, serde::Serialize)] +pub struct RetainedItem { + pub path: String, + pub reason: &'static str, +} + +fn companion_sources(files: &[FileEntry]) -> std::collections::HashSet { + let mut companion_extensions = std::collections::HashMap::new(); + for file in files { + if let (Some(parent), Some(stem)) = (file.path.parent(), file.path.file_stem()) { + companion_extensions + .entry((parent, stem)) + .or_insert_with(std::collections::HashSet::new) + .insert(file.path.extension()); + } + } + files.iter().filter(|file| { + file.path.parent().zip(file.path.file_stem()).is_some_and(|key| { + companion_extensions.get(&key).is_some_and(|extensions| extensions.len() > 1) + }) + }).map(|file| file.path.clone()).collect() +} + +pub fn organization_preview(files: &[FileEntry], moves: Vec) -> OrganizationPreview { + let companions = companion_sources(files); + let planned: std::collections::HashSet<&str> = moves.iter().map(|p| p.src.as_str()).collect(); + let retained = files.iter().filter(|f| !planned.contains(f.path.to_string_lossy().as_ref())) + .map(|f| RetainedItem { + path: f.path.to_string_lossy().into_owned(), + reason: if crate::safety::agent_state_guard::is_agent_state(&f.path) { + "agent_state" + } else if organization_boundary::package_ancestor(&f.path) { + "package_boundary" + } else if companions.contains(&f.path) { + "companion_bundle" + } else if f.path.parent().is_none_or(|parent| { + organization_boundary::validate_project_ancestors(parent).is_err() + }) { + "project_boundary_unverified" + } else { + "not_planned" + }, + }).collect(); + OrganizationPreview { whole_tree_verified: false, observed_file_count: files.len(), moves, retained } } #[cfg(not(coverage))] @@ -113,17 +179,27 @@ fn plan_moves_impl( ) -> Vec { let candidates: Vec<&str> = onto.classes.iter().map(|c| local_name(&c.id)).collect(); let reasoner = crate::ontology::Reasoner::build(onto); + let companions = companion_sources(files); let mut plans = Vec::new(); let mut lineage_probe_count = 0; for f in files { let Some(name) = f.path.file_name() else { continue }; + if crate::safety::agent_state_guard::is_agent_state(&f.path) + || organization_boundary::package_ancestor(&f.path) + || companions.contains(&f.path) + || f.path.parent().is_none_or(|parent| { + organization_boundary::validate_project_ancestors(parent).is_err() + }) + { + continue; + } let age_days = now_ms.saturating_sub(f.mtime_ms) / 86_400_000; - let local: String = match crate::userrules::classify_by_rules(rules, &f.path, f.size, age_days) { - Some(c) => c, + let (local, classification_source) = match crate::userrules::classify_by_rules(rules, &f.path, f.size, age_days) { + Some(c) => (c, "user_rule"), None => match pick(&f.path, &candidates) { - Some(picked) => picked, + Some(picked) => (picked, "model_picker"), None => match classify(&f.path) { - Some(c) => c.to_string(), + Some(c) => (c.to_string(), "extension"), None => continue, }, }, @@ -134,6 +210,10 @@ fn plan_moves_impl( continue; }; let dst = folder_path.join(name); + if crate::safety::agent_state_guard::is_agent_state(&dst) + || organization_boundary::package_ancestor(&dst) { + continue; + } if f.path.parent() == Some(folder_path.as_path()) { continue; } @@ -142,7 +222,7 @@ fn plan_moves_impl( lineage_probe_count += 1; probe(&f.path) } - Some(_) => Some(LineageMetadata::default()), + Some(_) => None, None => Some(LineageMetadata::default()), }; let Some(lineage) = lineage else { continue }; @@ -150,9 +230,11 @@ fn plan_moves_impl( src: f.path.to_string_lossy().into_owned(), dst: dst.to_string_lossy().into_owned(), class_id: class.id.clone(), + classification_source: Some(classification_source.into()), source_size: lineage_probe.map(|_| f.size), source_mtime_ms: lineage_probe.map(|_| f.mtime_ms), lineage, + bundle: None, }); } plans @@ -240,6 +322,15 @@ pub fn plan_moves_with_metadata( pub fn validate_move_source(plan: &MovePlan) -> Result<(), String> { let path = Path::new(&plan.src); + if let Some(expected) = &plan.bundle { + organization_boundary::validate_destination(Path::new(&plan.dst))?; + if organization_bundle::observe(path)? != *expected { + return Err("폴더 구성이나 내용이 바뀌어 묶음 이동을 보류합니다.".into()); + } + return Ok(()); + } + organization_boundary::validate_individual_move(path)?; + organization_boundary::validate_destination(Path::new(&plan.dst))?; let metadata = std::fs::symlink_metadata(path) .map_err(|_| "organize-source-unavailable".to_string())?; if metadata.file_type().is_symlink() || !metadata.is_file() { @@ -296,8 +387,15 @@ dm:Installer a owl:Class ; rdfs:label "설치파일"@ko ; dm:targetFolder "~/Ins FileEntry { path: PathBuf::from(p), size, mtime_ms: 0 } } - fn fe_at(p: &str, size: u64, mtime_ms: u64) -> FileEntry { - FileEntry { path: PathBuf::from(p), size, mtime_ms } + fn fixture_file(root: &Path, relative: &str, size: u64) -> FileEntry { + fixture_file_at(root, relative, size, 0) + } + + fn fixture_file_at(root: &Path, relative: &str, size: u64, mtime_ms: u64) -> FileEntry { + let path = root.join(relative.trim_start_matches('/')); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::File::create(&path).unwrap().set_len(size).unwrap(); + FileEntry { path, size, mtime_ms } } fn onto_with_target(target: &str) -> Ontology { @@ -310,11 +408,108 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . parse_ttl(&ttl.replace("TARGET", target)).unwrap() } + #[test] + fn legacy_plan_has_no_invented_classification_source() { + let legacy = r#"{"src":"/source","dst":"/destination","class_id":"class"}"#; + let plan: MovePlan = serde_json::from_str(legacy).unwrap(); + assert_eq!(plan.classification_source, None); + let restored: MovePlan = serde_json::from_str(&serde_json::to_string(&plan).unwrap()).unwrap(); + assert_eq!(restored.classification_source, None); + } + + #[test] + fn preview_explains_preserved_relationships_without_making_move_plans() { + let fixture = tempfile::tempdir().unwrap(); + let files = vec![fixture_file(fixture.path(), "/a/recording.wav", 1), fixture_file(fixture.path(), "/a/recording.tmk", 2), + fixture_file(fixture.path(), "/a/Editor.app/Contents/readme.txt", 3), fixture_file(fixture.path(), "/a/unknown.bin", 4)]; + let preview = organization_preview(&files, Vec::new()); + assert!(preview.moves.is_empty()); + assert!(!preview.whole_tree_verified); + assert_eq!(preview.observed_file_count, 4); + assert_eq!(preview.retained.iter().map(|item| item.reason).collect::>(), + vec!["companion_bundle", "companion_bundle", "package_boundary", "not_planned"]); + } + + #[test] + fn planner_preserves_companions_and_package_descendants_before_picking() { + let fixture = tempfile::tempdir().unwrap(); + let onto = parse_ttl(ONTO).unwrap(); + let files = vec![ + fixture_file(fixture.path(), "/downloads/recording.png", 10), + fixture_file(fixture.path(), "/downloads/recording.json", 20), + fixture_file(fixture.path(), "/downloads/Editor.app/Contents/image.png", 30), + ]; + let calls = Cell::new(0); + let plans = plan_moves_with(&files, &onto, Path::new("/home/u"), 0, &[], &|_, _| { + calls.set(calls.get() + 1); + Some("Image".into()) + }); + assert!(plans.is_empty()); + assert_eq!(calls.get(), 0); + } + + #[test] + fn planner_retains_project_documents_before_content_classification() { + let root = tempfile::tempdir().unwrap(); + let docs = root.path().join("docs"); + std::fs::create_dir(&docs).unwrap(); + let image = docs.join("photo.png"); + std::fs::write(&image, b"project image").unwrap(); + std::fs::write(root.path().join("Cargo.toml"), b"[package]").unwrap(); + let files = vec![fe(image.to_str().unwrap(), 13)]; + let plans = plan_moves_with(&files, &parse_ttl(ONTO).unwrap(), root.path(), + 0, &[], &|_, _| panic!("project content must not reach individual classification")); + assert!(plans.is_empty()); + let preview = organization_preview(&files, plans); + assert_eq!(preview.retained[0].reason, "project_boundary_unverified"); + assert_eq!(std::fs::read(image).unwrap(), b"project image"); + } + + #[test] + fn planner_retains_agent_sessions_before_classification_and_rejects_state_destinations() { + let fixture = tempfile::tempdir().unwrap(); + let files = vec![fixture_file(fixture.path(), "/project/.codex/sessions/session.png", 10), + fixture_file(fixture.path(), "/project/.claude/projects/conversation.png", 20)]; + let plans = plan_moves_with(&files, &parse_ttl(ONTO).unwrap(), Path::new("/home/u"), + 0, &[], &|_, _| panic!("session must not reach classification")); + let preview = organization_preview(&files, plans); + assert!(preview.moves.is_empty()); + assert_eq!(preview.retained.len(), 2); + assert!(preview.retained.iter().all(|item| item.reason == "agent_state")); + let ontology = onto_with_target("/project/.claude/archive"); + assert!(plan_moves(&[fixture_file(fixture.path(), "/downloads/photo.png", 1)], &ontology, + Path::new("/home/u")).is_empty()); + } + + #[test] + fn execution_rejects_companion_created_after_planning() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("recording.png"); + std::fs::write(&source, b"original").unwrap(); + let files = vec![FileEntry { path: source.clone(), size: 8, mtime_ms: 0 }]; + let plans = plan_moves(&files, &parse_ttl(ONTO).unwrap(), tmp.path()); + assert_eq!(plans.len(), 1); + assert!(validate_move_source(&plans[0]).is_ok()); + let companion = tmp.path().join("recording.json"); + std::fs::write(&companion, b"metadata").unwrap(); + assert_eq!(validate_move_source(&plans[0]).unwrap_err(), "organize-companion-bundle-required"); + assert_eq!(std::fs::read(source).unwrap(), b"original"); + assert_eq!(std::fs::read(companion).unwrap(), b"metadata"); + } + + #[test] + fn planner_rejects_destination_inside_package() { + let fixture = tempfile::tempdir().unwrap(); + let ontology = onto_with_target("/Applications/Editor.app/Contents/Documents"); + assert!(plan_moves(&[fixture_file(fixture.path(), "/downloads/photo.png", 1)], &ontology, Path::new("/home/u")).is_empty()); + } + #[test] fn plans_move_to_resolved_target_folder() { + let fixture = tempfile::tempdir().unwrap(); let onto = parse_ttl(ONTO).unwrap(); let home = Path::new("/home/u"); - let files = vec![fe("/downloads/pic.png", 100)]; + let files = vec![fixture_file(fixture.path(), "/downloads/pic.png", 100)]; let plans = plan_moves(&files, &onto, home); assert_eq!(plans.len(), 1); // ~ → home, {class} → Image @@ -366,15 +561,17 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . #[test] fn metadata_probe_is_bounded_per_plan() { + let fixture = tempfile::tempdir().unwrap(); let onto = parse_ttl(ONTO).unwrap(); + let home = tempfile::tempdir().unwrap(); let files = (0..MAX_LINEAGE_PROBES + 1) - .map(|i| fe(&format!("/downloads/{i}.png"), 1)) + .map(|i| fixture_file(fixture.path(), &format!("/downloads/{i}.png"), 1)) .collect::>(); let probes = Cell::new(0); let plans = plan_moves_with_metadata( &files, &onto, - Path::new("/home/u"), + home.path(), 1_800_000_000_000, &[], &|_, _| None, @@ -384,38 +581,42 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . }, ); assert_eq!(probes.get(), MAX_LINEAGE_PROBES); - assert_eq!(plans.len(), MAX_LINEAGE_PROBES + 1); - assert_eq!(plans[MAX_LINEAGE_PROBES].src, format!("/downloads/{}.png", MAX_LINEAGE_PROBES)); - assert_eq!(plans[MAX_LINEAGE_PROBES].source_size, Some(1)); - assert!(plans[MAX_LINEAGE_PROBES].lineage.lineage_fingerprint.is_empty()); + assert_eq!(plans.len(), MAX_LINEAGE_PROBES); + let preview = organization_preview(&files, plans); + assert_eq!(preview.retained.len(), 1); + assert_eq!(preview.retained[0].path, fixture.path().join(format!("downloads/{}.png", MAX_LINEAGE_PROBES)).to_string_lossy()); } #[test] fn skips_unclassified_and_targetless() { + let fixture = tempfile::tempdir().unwrap(); let onto = parse_ttl(ONTO).unwrap(); let home = Path::new("/home/u"); let files = vec![ - fe("/x/unknown.xyz", 10), // 미분류 → 제외 - fe("/x/main.rs", 20), // Code: targetFolder 없음 → 제외 + fixture_file(fixture.path(), "/x/unknown.xyz", 10), // 미분류 → 제외 + fixture_file(fixture.path(), "/x/main.rs", 20), // Code: targetFolder 없음 → 제외 ]; assert!(plan_moves(&files, &onto, home).is_empty()); } #[test] fn skips_file_already_in_destination() { + let fixture = tempfile::tempdir().unwrap(); let onto = parse_ttl(ONTO).unwrap(); - let home = Path::new("/home/u"); + let home_path = fixture.path().join("home/u"); + let home = home_path.as_path(); // 이미 목적지 폴더에 있는 파일 - let files = vec![fe("/home/u/Media/Image/pic.png", 100)]; + let files = vec![fixture_file(fixture.path(), "/home/u/Media/Image/pic.png", 100)]; assert!(plan_moves(&files, &onto, home).is_empty()); } #[test] fn skips_classified_file_whose_class_absent_from_ontology() { + let fixture = tempfile::tempdir().unwrap(); // mp4 → classify "Video"지만 ONTO엔 Video 클래스가 없음 → 클래스 조회 else(continue) 커버 let onto = parse_ttl(ONTO).unwrap(); let home = Path::new("/home/u"); - assert!(plan_moves(&[fe("/x/movie.mp4", 100)], &onto, home).is_empty()); + assert!(plan_moves(&[fixture_file(fixture.path(), "/x/movie.mp4", 100)], &onto, home).is_empty()); } #[test] @@ -428,10 +629,11 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . #[test] fn target_folder_without_class_placeholder_is_used_verbatim() { + let fixture = tempfile::tempdir().unwrap(); // ~/Installers 처럼 {class} 없는 targetFolder — 치환 없이 그대로, filename만 붙는다 let onto = parse_ttl(ONTO).unwrap(); let home = Path::new("/home/u"); - let files = vec![fe("/downloads/setup.exe", 100)]; + let files = vec![fixture_file(fixture.path(), "/downloads/setup.exe", 100)]; let plans = plan_moves(&files, &onto, home); assert_eq!(plans.len(), 1); let expected = Path::new("/home/u/Installers").join("setup.exe"); @@ -440,6 +642,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "TARGET" . #[test] fn target_folder_without_tilde_is_absolute() { + let fixture = tempfile::tempdir().unwrap(); // ~ 없는 절대경로 targetFolder — home 치환 없이 그대로 let ttl = r#" @prefix owl: . @@ -449,7 +652,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ "#; let onto = parse_ttl(ttl).unwrap(); let home = Path::new("/home/u"); - let files = vec![fe("/downloads/pic.png", 100)]; + let files = vec![fixture_file(fixture.path(), "/downloads/pic.png", 100)]; let plans = plan_moves(&files, &onto, home); assert_eq!(plans.len(), 1); let expected = Path::new("/opt/media/Image").join("pic.png"); @@ -458,44 +661,50 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ #[test] fn rejects_relative_target_folder_that_depends_on_process_cwd() { + let fixture = tempfile::tempdir().unwrap(); let onto = onto_with_target("relative/{class}"); - let plans = plan_moves(&[fe("/downloads/pic.png", 100)], &onto, Path::new("/home/u")); + let plans = plan_moves(&[fixture_file(fixture.path(), "/downloads/pic.png", 100)], &onto, Path::new("/home/u")); assert!(plans.is_empty()); } #[test] fn rejects_parent_traversal_in_home_relative_target_folder() { + let fixture = tempfile::tempdir().unwrap(); let onto = onto_with_target("~/Media/../escape/{class}"); - let plans = plan_moves(&[fe("/downloads/pic.png", 100)], &onto, Path::new("/home/u")); + let plans = plan_moves(&[fixture_file(fixture.path(), "/downloads/pic.png", 100)], &onto, Path::new("/home/u")); assert!(plans.is_empty()); } #[test] fn rejects_parent_traversal_in_absolute_target_folder() { + let fixture = tempfile::tempdir().unwrap(); let onto = onto_with_target("/opt/media/../escape/{class}"); - let plans = plan_moves(&[fe("/downloads/pic.png", 100)], &onto, Path::new("/home/u")); + let plans = plan_moves(&[fixture_file(fixture.path(), "/downloads/pic.png", 100)], &onto, Path::new("/home/u")); assert!(plans.is_empty()); } #[test] fn rejects_named_tilde_target_that_is_not_home_token() { + let fixture = tempfile::tempdir().unwrap(); let onto = onto_with_target("~other/{class}"); - let plans = plan_moves(&[fe("/downloads/pic.png", 100)], &onto, Path::new("/home/u")); + let plans = plan_moves(&[fixture_file(fixture.path(), "/downloads/pic.png", 100)], &onto, Path::new("/home/u")); assert!(plans.is_empty()); } #[test] fn preserves_literal_tilde_inside_absolute_target_folder() { + let fixture = tempfile::tempdir().unwrap(); let onto = onto_with_target("/opt/~archive/{class}"); - let plans = plan_moves(&[fe("/downloads/pic.png", 100)], &onto, Path::new("/home/u")); + let plans = plan_moves(&[fixture_file(fixture.path(), "/downloads/pic.png", 100)], &onto, Path::new("/home/u")); assert_eq!(plans.len(), 1); assert_eq!(plans[0].dst, "/opt/~archive/Image/pic.png"); } #[test] fn rejects_home_relative_target_when_home_is_relative() { + let fixture = tempfile::tempdir().unwrap(); let onto = parse_ttl(ONTO).unwrap(); - let plans = plan_moves(&[fe("/downloads/pic.png", 100)], &onto, Path::new(".")); + let plans = plan_moves(&[fixture_file(fixture.path(), "/downloads/pic.png", 100)], &onto, Path::new(".")); assert!(plans.is_empty()); } @@ -503,7 +712,8 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ #[test] fn windows_home_relative_target_uses_native_absolute_path() { let home = PathBuf::from(r"C:\Users\u"); - let files = [fe(r"C:\downloads\pic.png", 100)]; + let fixture = tempfile::tempdir().unwrap(); + let files = [fixture_file(fixture.path(), "downloads/pic.png", 100)]; let plans = plan_moves(&files, &onto_with_target("~/Media/{class}"), &home); assert_eq!(plans.len(), 1); assert_eq!(plans[0].dst, r"C:\Users\u\Media\Image\pic.png"); @@ -513,7 +723,8 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ #[test] fn windows_relative_target_fails_closed() { let home = PathBuf::from(r"C:\Users\u"); - let files = [fe(r"C:\downloads\pic.png", 100)]; + let fixture = tempfile::tempdir().unwrap(); + let files = [fixture_file(fixture.path(), "downloads/pic.png", 100)]; assert!(plan_moves(&files, &onto_with_target("relative/{class}"), &home).is_empty()); } @@ -529,35 +740,40 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ #[test] fn picker_choice_overrides_extension_classify() { + let fixture = tempfile::tempdir().unwrap(); // main.rs는 확장자로 "Code"(targetFolder 없음 → 평소 제외)로 분류되지만, // picker가 "Image"(targetFolder 있음)를 고르면 Image 목적지로 계획된다. let onto = parse_ttl(ONTO).unwrap(); let home = Path::new("/home/u"); - let files = vec![fe("/src/main.rs", 20)]; + let files = vec![fixture_file(fixture.path(), "/src/main.rs", 20)]; let pick = |_p: &Path, _c: &[&str]| Some("Image".to_string()); let plans = plan_moves_with(&files, &onto, home, 0, &[], &pick); assert_eq!(plans.len(), 1); + assert_eq!(plans[0].classification_source.as_deref(), Some("model_picker")); assert!(plans[0].class_id.ends_with("Image")); } #[test] fn picker_none_falls_back_to_extension_classify() { + let fixture = tempfile::tempdir().unwrap(); // picker가 None이면 기존 확장자 분류(pic.png → Image)로 폴백 — plan_moves와 동일 let onto = parse_ttl(ONTO).unwrap(); let home = Path::new("/home/u"); - let files = vec![fe("/downloads/pic.png", 100)]; + let files = vec![fixture_file(fixture.path(), "/downloads/pic.png", 100)]; let pick = |_p: &Path, _c: &[&str]| None; let plans = plan_moves_with(&files, &onto, home, 0, &[], &pick); assert_eq!(plans.len(), 1); + assert_eq!(plans[0].classification_source.as_deref(), Some("extension")); assert!(plans[0].class_id.ends_with("Image")); } #[test] fn picker_candidates_include_ontology_class_names() { + let fixture = tempfile::tempdir().unwrap(); // picker에 넘어오는 후보 목록이 온톨로지 클래스 로컬명을 포함하는지 확인 let onto = parse_ttl(ONTO).unwrap(); let home = Path::new("/home/u"); - let files = vec![fe("/downloads/pic.png", 100)]; + let files = vec![fixture_file(fixture.path(), "/downloads/pic.png", 100)]; let seen = std::cell::RefCell::new(Vec::::new()); let pick = |_p: &Path, cands: &[&str]| { *seen.borrow_mut() = cands.iter().map(|s| s.to_string()).collect(); @@ -571,6 +787,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ #[test] fn user_rule_overrides_picker_and_extension() { + let fixture = tempfile::tempdir().unwrap(); // pic.png는 확장자로 Image지만, 사용자 규칙(ext png → Installer)이 우선 → Installer 목적지 let onto = parse_ttl(ONTO).unwrap(); let home = Path::new("/home/u"); @@ -579,8 +796,9 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ class: "Installer".into(), }]; let pick = |_p: &Path, _c: &[&str]| Some("Image".to_string()); // picker가 Image를 골라도 - let plans = plan_moves_with(&[fe("/d/pic.png", 10)], &onto, home, 0, &rules, &pick); + let plans = plan_moves_with(&[fixture_file(fixture.path(), "/d/pic.png", 10)], &onto, home, 0, &rules, &pick); assert_eq!(plans.len(), 1); + assert_eq!(plans[0].classification_source.as_deref(), Some("user_rule")); assert!(plans[0].class_id.ends_with("Installer")); // 규칙이 picker를 이긴다 // 규칙이 우선하므로 plan_moves_with 내부에서 pick은 호출되지 않는다(설계상 의도). // 라인 커버리지 확보를 위해 클로저 자체가 유효한 picker임을 별도로 확인. @@ -589,6 +807,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ #[test] fn no_user_rule_match_falls_through_to_picker() { + let fixture = tempfile::tempdir().unwrap(); // 규칙이 있으나 매칭 안 되면(ext iso) 기존 precedence(picker→classify)로 let onto = parse_ttl(ONTO).unwrap(); let home = Path::new("/home/u"); @@ -597,13 +816,14 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ class: "Installer".into(), }]; let pick = |_p: &Path, _c: &[&str]| None; - let plans = plan_moves_with(&[fe("/d/pic.png", 10)], &onto, home, 0, &rules, &pick); + let plans = plan_moves_with(&[fixture_file(fixture.path(), "/d/pic.png", 10)], &onto, home, 0, &rules, &pick); assert_eq!(plans.len(), 1); assert!(plans[0].class_id.ends_with("Image")); // 확장자 폴백 } #[test] fn user_rule_age_predicate_matches_old_file_only() { + let fixture = tempfile::tempdir().unwrap(); // now = 100 days in ms; rule: min_age_days 30 → Installer. Old file (mtime 0 → age 100d) matches; fresh (mtime≈now → age 0) doesn't. let onto = parse_ttl(ONTO).unwrap(); let home = Path::new("/home/u"); @@ -614,17 +834,18 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ }]; let pick = |_p: &Path, _c: &[&str]| None; // old file → age 100d ≥ 30 → rule matches → Installer target - let old = plan_moves_with(&[fe_at("/d/pic.png", 10, 0)], &onto, home, now, &rules, &pick); + let old = plan_moves_with(&[fixture_file_at(fixture.path(), "/d/pic.png", 10, 0)], &onto, home, now, &rules, &pick); assert_eq!(old.len(), 1); assert!(old[0].class_id.ends_with("Installer")); // fresh file → age 0 < 30 → rule skips → extension classify (png→Image) - let fresh = plan_moves_with(&[fe_at("/d/pic.png", 10, now)], &onto, home, now, &rules, &pick); + let fresh = plan_moves_with(&[fixture_file_at(fixture.path(), "/d/pic.png", 10, now)], &onto, home, now, &rules, &pick); assert_eq!(fresh.len(), 1); assert!(fresh[0].class_id.ends_with("Image")); } #[test] fn future_dated_file_saturates_to_age_zero() { + let fixture = tempfile::tempdir().unwrap(); // mtime > now (future-dated / clock skew): saturating_sub → age 0, no panic/underflow. // rule min_age_days: 1 → age 0 < 1 → no match → extension classify (png → Image). let onto = parse_ttl(ONTO).unwrap(); @@ -636,7 +857,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ class: "Installer".into(), }]; let pick = |_p: &Path, _c: &[&str]| None; - let plans = plan_moves_with(&[fe_at("/d/pic.png", 10, future)], &onto, home, now, &rules, &pick); + let plans = plan_moves_with(&[fixture_file_at(fixture.path(), "/d/pic.png", 10, future)], &onto, home, now, &rules, &pick); assert_eq!(plans.len(), 1); assert!(plans[0].class_id.ends_with("Image")); // age saturated to 0 → rule skipped → ext classify } diff --git a/src-tauri/src/safety.rs b/src-tauri/src/safety.rs index 959bff1c5..388d66d1b 100644 --- a/src-tauri/src/safety.rs +++ b/src-tauri/src/safety.rs @@ -9,6 +9,7 @@ pub enum SafetyError { Protected(PathBuf), Trash(String), Journal(String), + Validation(String), } impl std::fmt::Display for SafetyError { @@ -17,6 +18,7 @@ impl std::fmt::Display for SafetyError { SafetyError::Protected(p) => write!(f, "보호된 경로: {}", p.display()), SafetyError::Trash(e) => write!(f, "휴지통 이동 실패: {e}"), SafetyError::Journal(e) => write!(f, "저널 기록 실패: {e}"), + SafetyError::Validation(e) => f.write_str(e), } } } @@ -176,6 +178,17 @@ pub fn filesystem_object_id(path: &Path) -> std::io::Result { } } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct MovePaths { + /// Producer provenance, not semantic verification or deletion authority. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub classification_source: Option, + pub source: PathBuf, + pub destination: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bundle: Option, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct JournalEntry { pub ts_ms: u64, @@ -183,6 +196,8 @@ pub struct JournalEntry { pub path: String, pub bytes: u64, pub outcome: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub move_paths: Option, } /// std::io 오류를 SafetyError::Journal로 감싸는 공용 매퍼. @@ -316,6 +331,7 @@ pub fn trash_delete( path: path.to_string_lossy().into_owned(), bytes, outcome: "pending".into(), + move_paths: None, }; journal_append(journal_path, &entry)?; // fsync 없음(의식적 선택): 삭제는 휴지통 경유라 전원 단절로 pending 기록을 잃어도 복구 가능 @@ -490,6 +506,7 @@ pub fn trash_delete_if_identity( path: path.to_string_lossy().into_owned(), bytes, outcome: "pending".into(), + move_paths: None, }; if let Err(error) = journal_append(journal_path, &entry) { let _ = std::fs::remove_dir(&staging_dir); @@ -666,16 +683,6 @@ fn copy_verified_io(src: &Path, dst: &Path) -> std::io::Result<()> { Ok(()) } -/// 분기 결정(same_vol)을 파라미터로 받아 양 경로를 플랫폼 무관하게 테스트 가능하게 한다. -/// 같은 볼륨 이동 io — hard_link(create-only) 후 원본 링크 제거. 두 io 에러 모두 `?`로 -/// 전파(커버리지 규율: happy path에서 map_err 클로저가 미실행 라인으로 남지 않도록). -/// dst가 이미 있으면 hard_link가 AlreadyExists로 실패해 덮어쓰지 않는다. -fn hardlink_move_io(src: &Path, dst: &Path) -> std::io::Result<()> { - std::fs::hard_link(src, dst)?; - std::fs::remove_file(src)?; - Ok(()) -} - /// move_file이 same_volume()로 실제 결정을 주입한다. fn do_move( src: &Path, @@ -683,6 +690,9 @@ fn do_move( same_vol: bool, journal_path: &Path, now_ms: u64, + validate: &dyn Fn() -> Result<(), SafetyError>, + bundle: Option<&crate::organize::organization_bundle::BundleManifest>, + classification_source: Option<&str>, ) -> Result<(), SafetyError> { let mut entry = JournalEntry { ts_ms: now_ms, @@ -690,21 +700,20 @@ fn do_move( path: format!("{} -> {}", src.display(), dst.display()), bytes: std::fs::metadata(src).map(|m| m.len()).unwrap_or(0), outcome: "pending".into(), + move_paths: Some(MovePaths { classification_source: classification_source.map(str::to_owned), source: src.to_path_buf(), destination: dst.to_path_buf(), bundle: bundle.cloned() }), }; journal_append(journal_path, &entry)?; let result = if same_vol { - // rename은 dst를 원자적으로 덮어쓴다(REPLACE) → dst.exists() 체크 이후 경합으로 생긴 - // 파일이 휴지통도 안 거치고 영구 소실될 수 있다. hard_link는 create-only라 dst가 이미 - // 있으면 AlreadyExists로 실패(덮어쓰지 않음) — 링크 성공 후 원본 링크만 제거한다. - // 두 단계 사이 크래시 시엔 양쪽이 같은 inode를 가리키는 무해한 중복이 남는다(손실 아님). - // io는 헬퍼가 `?`로 전파 → happy path에서 map_err 클로저가 미실행 라인으로 남지 않는다. - // 단일 경계 map_err은 hard_link 실패 테스트(dest-exists)가 커버한다. - hardlink_move_io(src, dst).map_err(|e| SafetyError::Trash(e.to_string())) + // One exclusive rename avoids the intermediate two-name state of link/unlink. + #[cfg(target_os = "macos")] + { coordinated_rename(src, dst, validate) } + #[cfg(not(target_os = "macos"))] + { validate().and_then(|()| rename_noreplace(src, dst).map_err(|e| SafetyError::Trash(e.to_string()))) } } else { // 크로스 볼륨: 복사+검증 후 원본 휴지통 (영구 삭제 없음) - copy_verified_io(src, dst) - .map_err(|e| SafetyError::Trash(e.to_string())) + validate().and_then(|()| copy_verified_io(src, dst) + .map_err(|e| SafetyError::Trash(e.to_string()))) .and_then(|()| { let bytes = std::fs::metadata(dst).map(|m| m.len()).unwrap_or(0); trash_delete(src, bytes, journal_path, now_ms) @@ -721,11 +730,32 @@ fn do_move( /// 앱 유일의 이동 경로 (스펙 §7-2). 영구 삭제 없음 — 원본 제거는 trash_delete 경유. pub fn move_file( - src: &Path, - dst: &Path, - journal_path: &Path, - now_ms: u64, + src: &Path, dst: &Path, journal_path: &Path, now_ms: u64, ) -> Result<(), SafetyError> { + move_file_checked(src, dst, journal_path, now_ms, &|| Ok(()), None, None) +} + +/// Validate before preparation and again inside the native move accessor. +pub(crate) fn move_file_checked( + src: &Path, dst: &Path, journal_path: &Path, now_ms: u64, + validate: &dyn Fn() -> Result<(), SafetyError>, + bundle: Option<&crate::organize::organization_bundle::BundleManifest>, + classification_source: Option<&str>, +) -> Result<(), SafetyError> { + validate_move_paths(src, dst)?; + validate()?; + if dst.exists() { + return Err(SafetyError::Trash(format!("목적지가 이미 존재: {}", dst.display()))); + } + let dst_parent = dst.parent().unwrap_or(dst); + std::fs::create_dir_all(dst_parent).map_err(|e| SafetyError::Trash(e.to_string()))?; + if bundle.is_some() && !same_volume(src, dst) { + return Err(SafetyError::Validation("묶음은 같은 볼륨 안에서만 옮길 수 있습니다.".into())); + } + do_move(src, dst, same_volume(src, dst), journal_path, now_ms, validate, bundle, classification_source) +} + +fn validate_move_paths(src: &Path, dst: &Path) -> Result<(), SafetyError> { // 보호: src·dst 양쪽, ParentDir 거부, verbatim 정규화 — trash_delete와 동일 리거 for p in [src, dst] { if p.components().any(|c| matches!(c, std::path::Component::ParentDir)) { @@ -738,17 +768,68 @@ pub fn move_file( return Err(SafetyError::Protected(p.to_path_buf())); } } - // 목적지 충돌 금지 (덮어쓰기 방지) - if dst.exists() { - return Err(SafetyError::Trash(format!("목적지가 이미 존재: {}", dst.display()))); - } - // 목적지 부모 디렉토리 생성. 위 protected 검사가 parent 없는 경로를 이미 거부했으므로 - // parent는 항상 Some — 폴백(dst 자신)은 실제로 도달 불가지만, 패닉(expect) 대신 한 줄 - // unwrap_or로 두어 라인 커버리지를 유지하면서 방어한다(도달 시 create_dir_all이 에러로 귀결). - let dst_parent = dst.parent().unwrap_or(dst); - std::fs::create_dir_all(dst_parent).map_err(|e| SafetyError::Trash(e.to_string()))?; + Ok(()) +} - do_move(src, dst, same_volume(src, dst), journal_path, now_ms) +#[cfg(target_os = "macos")] +fn coordinated_rename( + src: &Path, dst: &Path, validate: &dyn Fn() -> Result<(), SafetyError>, +) -> Result<(), SafetyError> { + use std::{cell::Cell, ptr::NonNull}; + use block2::StackBlock; + use objc2::rc::autoreleasepool; + use objc2_foundation::{NSFileCoordinator, NSFileCoordinatorWritingOptions, NSString, NSURL}; + + let io_error = |e: std::io::Error| SafetyError::Trash(e.to_string()); + let source = std::path::absolute(src).map_err(io_error)?; + let destination = std::path::absolute(dst).map_err(io_error)?; + let source_id = filesystem_object_id(&source).map_err(io_error)?; + let parent = destination.parent().ok_or_else(|| SafetyError::Protected(destination.clone()))?; + let parent_id = filesystem_object_id(&std::fs::canonicalize(parent).map_err(io_error)?).map_err(io_error)?; + let source_text = source.to_str().ok_or_else(|| SafetyError::Protected(source.clone()))?; + let destination_text = destination.to_str().ok_or_else(|| SafetyError::Protected(destination.clone()))?; + autoreleasepool(|_| { + let source_url = NSURL::fileURLWithPath(&NSString::from_str(source_text)); + let destination_url = NSURL::fileURLWithPath(&NSString::from_str(destination_text)); + let coordinator = NSFileCoordinator::new(); + let outcome = Cell::new(None); + let invoked = Cell::new(false); + let accessor = StackBlock::new(|from: NonNull, to: NonNull| { + if invoked.replace(true) { + outcome.set(Some(Err(SafetyError::Trash("file coordination invoked twice".into())))); + return; + } + // SAFETY: Foundation lends these non-null URLs for the synchronous accessor call. + let (from, to) = unsafe { (from.as_ref(), to.as_ref()) }; + let result = (|| { + let actual_source = from.path().map(|p| PathBuf::from(p.to_string())); + let actual_destination = to.path().map(|p| PathBuf::from(p.to_string())); + if actual_source.as_ref() != Some(&source) || actual_destination.as_ref() != Some(&destination) { + return Err(SafetyError::Trash("file coordination changed the planned paths".into())); + } + validate_move_paths(&source, &destination)?; + if filesystem_object_id(&source).map_err(io_error)? != source_id + || filesystem_object_id(&std::fs::canonicalize(parent).map_err(io_error)?).map_err(io_error)? != parent_id { + return Err(SafetyError::Trash("file identity changed while waiting for coordination".into())); + } + validate()?; + coordinator.itemAtURL_willMoveToURL(from, to); + rename_noreplace(&source, &destination).map_err(io_error)?; + coordinator.itemAtURL_didMoveToURL(from, to); + Ok(()) + })(); + outcome.set(Some(result)); + }); + let mut error = None; + coordinator.coordinateWritingItemAtURL_options_writingItemAtURL_options_error_byAccessor( + &source_url, NSFileCoordinatorWritingOptions::ForMoving, + &destination_url, NSFileCoordinatorWritingOptions::empty(), Some(&mut error), &accessor, + ); + if let Some(error) = error { + return Err(SafetyError::Trash(error.localizedDescription().to_string())); + } + outcome.take().unwrap_or_else(|| Err(SafetyError::Trash("file coordination did not run".into()))) + }) } #[cfg(test)] @@ -800,6 +881,7 @@ mod tests { assert!(SafetyError::Protected(PathBuf::from("/x")).to_string().contains("보호")); assert!(SafetyError::Trash("boom".into()).to_string().contains("휴지통")); assert!(SafetyError::Journal("boom".into()).to_string().contains("저널")); + assert_eq!(SafetyError::Validation("source changed".into()).to_string(), "source changed"); } #[test] @@ -841,6 +923,17 @@ mod tests { assert!(!is_protected(Path::new("C:\\Windows.old"))); // 정당한 정리 대상 } + #[test] + fn legacy_move_journal_keeps_classification_source_unknown() { + let raw = r#"{"ts_ms":1,"op":"move","path":"legacy","bytes":1,"outcome":"ok","move_paths":{"source":"/a","destination":"/b"}}"#; + let entry: JournalEntry = serde_json::from_str(raw).unwrap(); + assert!(entry.move_paths.as_ref().unwrap().classification_source.is_none()); + let encoded = serde_json::to_string(&entry).unwrap(); + assert!(!encoded.contains("classification_source")); + let restored: JournalEntry = serde_json::from_str(&encoded).unwrap(); + assert_eq!(restored.move_paths.unwrap().source, Path::new("/a")); + } + #[test] fn journal_roundtrip_newest_first() { let tmp = tempfile::tempdir().unwrap(); @@ -854,6 +947,7 @@ mod tests { path: format!("/x/{i}"), bytes: i * 10, outcome: "ok".into(), + move_paths: None, }, ) .unwrap(); @@ -882,6 +976,7 @@ mod tests { path: "/x".into(), bytes: 0, outcome: "ok".into(), + move_paths: None, }, ); assert!(matches!(err, Err(SafetyError::Journal(_)))); @@ -1122,6 +1217,7 @@ mod tests { path: "/x".into(), bytes: 0, outcome: "ok".into(), + move_paths: None, }, ) .unwrap(); @@ -1199,23 +1295,21 @@ mod tests { let src = tmp.path().join("a.bin"); let dst = tmp.path().join("b.bin"); std::fs::write(&src, vec![7u8; 30]).unwrap(); - do_move(&src, &dst, true, &jp, 1).unwrap(); + do_move(&src, &dst, true, &jp, 1, &|| Ok(()), None, None).unwrap(); assert!(!src.exists()); assert_eq!(std::fs::read(&dst).unwrap().len(), 30); } - // Fix 1 회귀 테스트: hard_link는 create-only라 dst가 이미 있으면(TOCTOU 경합으로 그 사이 - // 생긴 파일 시뮬레이션) AlreadyExists로 실패해야 하며, 그 경합 상대의 dst도 원본 src도 - // 절대 건드리면 안 된다 — rename의 REPLACE 시맨틱이었다면 여기서 dst가 파괴됐을 것. + // A destination created after admission must survive the exclusive move unchanged. #[test] - fn do_move_same_volume_hard_link_fails_when_dest_exists() { + fn do_move_same_volume_fails_when_dest_exists() { let tmp = tempfile::tempdir().unwrap(); let jp = tmp.path().join("j.jsonl"); let src = tmp.path().join("a.bin"); let dst = tmp.path().join("b.bin"); std::fs::write(&src, b"original").unwrap(); std::fs::write(&dst, b"pre-existing").unwrap(); // TOCTOU 경합에서 먼저 생긴 것처럼 시뮬레이션 - let err = do_move(&src, &dst, true, &jp, 1); + let err = do_move(&src, &dst, true, &jp, 1, &|| Ok(()), None, None); assert!(matches!(err, Err(SafetyError::Trash(_)))); assert!(src.exists(), "원본은 실패 시 보존"); assert_eq!( @@ -1234,7 +1328,7 @@ mod tests { let dst = tmp.path().join("moved-disksage-xvol-fixture.bin"); std::fs::write(&src, vec![9u8; 40]).unwrap(); // same_vol=false 강제 → 실제 같은 볼륨이어도 copy+verify+trash 경로 실행 - do_move(&src, &dst, false, &jp, 2).unwrap(); + do_move(&src, &dst, false, &jp, 2, &|| Ok(()), None, None).unwrap(); assert!(!src.exists(), "원본은 휴지통으로"); assert_eq!(std::fs::read(&dst).unwrap().len(), 40); // 원본이 휴지통에 있음 확인 후 테스트 픽스처만 purge diff --git a/src/lib/Organize.svelte b/src/lib/Organize.svelte index adac77371..4afbe722e 100644 --- a/src/lib/Organize.svelte +++ b/src/lib/Organize.svelte @@ -7,12 +7,23 @@ let { scannedRoot }: { scannedRoot: string | null } = $props(); let plans: api.MovePlan[] = $state([]); + let previewLoaded = $state(false); + let observedFileCount = $state(0); + let retained: api.OrganizationPreview["retained"] = $state([]); let busy = $state(false); + let bundleParent = $state(""); + let bundlePlan: api.MovePlan | null = $state(null); let loadError = $state(""); let results: api.CleanResult[] = $state([]); let verdicts: Record = $state({}); let exportStatus = $state(""); + $effect(() => { + scannedRoot; + bundleParent; + bundlePlan = null; + }); + async function loadVerdicts(paths: string[]) { try { const fvs = await api.fileVerdicts(paths); @@ -27,8 +38,15 @@ busy = true; loadError = ""; results = []; + plans = []; + retained = []; + previewLoaded = false; try { - plans = await api.planOrganize(scannedRoot); + const preview = await api.planOrganize(scannedRoot); + plans = preview.moves; + observedFileCount = preview.observed_file_count; + retained = preview.retained; + previewLoaded = true; loadVerdicts(plans.map((p) => p.src)); } catch (e) { loadError = String(e); @@ -37,6 +55,23 @@ } } + async function loadBundlePlan() { + if (!scannedRoot) return; + busy = true; + loadError = ""; + bundlePlan = null; + const root = scannedRoot; + const parent = bundleParent; + try { + const planned = await api.planBundleOrganize(root, parent); + if (root === scannedRoot && parent === bundleParent) bundlePlan = planned; + } catch (error) { + loadError = String(error); + } finally { + busy = false; + } + } + // Group plans by class_id for display let grouped = $derived.by(() => { const g = new Map(); @@ -47,19 +82,20 @@ return Array.from(g.entries()); }); - async function executeSelected() { - if (plans.length === 0) return; + async function executeSelected(selected: api.MovePlan[]) { + if (selected.length === 0) return; const okay = await confirm( - `${plans.length}개 파일을 정리합니다 (온톨로지 targetFolder로 이동).\n` + - `되돌리기 버튼으로 복원할 수 있습니다.`, + `${selected.length}개 항목을 미리보기에 표시된 폴더로 옮깁니다.\n` + + `이동 기록을 남깁니다. 변경이나 경로 충돌이 있으면 되돌리기를 보류합니다.`, { title: "DiskSage", kind: "warning" }, ); if (!okay) return; busy = true; try { - const r = await api.executeMoves(plans); + const r = await api.executeMoves(selected); results = r; plans = []; + bundlePlan = null; } catch (e) { loadError = String(e); } finally { @@ -103,10 +139,46 @@ 미리보기/실행 상태와 무관하게 항상 노출되어야 한다(그렇지 않으면 재-미리보기로 사라짐). --> +
+ 선택한 폴더를 기존 묶음 그대로 이동 +

주제를 자동 분류하지 않고 현재 폴더 이름과 구성원을 함께 보존합니다. 현재는 하위 폴더 없이 로컬 파일 32개, 합계 8MiB 이하인 문서 묶음을 지원합니다.

+ + + {#if bundlePlan?.bundle} +

{bundlePlan.src} → {bundlePlan.dst}

+
    {#each bundlePlan.bundle.files as file (file.name)}
  • {file.name} · {fmtBytes(file.bytes)}
  • {/each}
+ + {/if} +
{#if loadError}

{loadError}

{/if} {#if plans.length === 0 && !busy} -

미리보기를 눌러 정리 계획을 확인하세요.

+

{previewLoaded ? "이번 미리보기에서 이동할 파일은 없습니다." : "미리보기를 눌러 정리 계획을 확인하세요."}

+ {/if} + + {#if previewLoaded} +

확인한 파일 {observedFileCount}개를 바탕으로 한 미리보기입니다. 전체 폴더 조사가 완료됐다는 뜻은 아닙니다.

+ {/if} + + {#if retained.length > 0} +
+ 현재 위치에 유지할 파일 {retained.length}개 +
    + {#each retained as item (item.path)} +
  • {item.path} — {item.reason === "agent_state" + ? "대화와 작업 상태를 보존하기 위해 현재 위치에 유지합니다." + : item.reason === "package_boundary" + ? "앱이나 프로젝트 묶음 내부 파일이므로 따로 옮기지 않습니다." + : item.reason === "companion_bundle" + ? "함께 보존할 파일이 있어 한 파일만 따로 옮기지 않습니다." + : item.reason === "project_boundary_unverified" + ? "프로젝트 내부 자료이거나 경계를 확인할 수 없어 현재 위치에 보존합니다." + : "이번 미리보기에는 이동 계획이 없습니다. 현재 위치에 보존합니다."}
  • + {/each} +
+
{/if} {#each grouped as [classId, group] (classId)} @@ -116,6 +188,13 @@ {#each group as p (p.src)}
  • {p.src} + {p.classification_source === "user_rule" + ? "사용자 규칙에 따른 제안 · 내용 검증 안 됨" + : p.classification_source === "model_picker" + ? "AI 분류 제안 · 내용 검증 안 됨" + : p.classification_source === "extension" + ? "파일 형식에 따른 제안 · 내용 검증 안 됨" + : "분류 근거 확인 필요"} {#if verdicts[p.src]} {@const b = verdictBadge(verdicts[p.src])} {b.label} @@ -136,7 +215,7 @@ {#if plans.length > 0}
    -