From bc7298927939192920f7248aca4e6107e6f8e50e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:10:32 +0900 Subject: [PATCH 01/66] test: define redacted Podman desktop evidence contract --- src-tauri/src/podman_desktop.rs | 463 ++++++++++++++++++++++++++++++++ 1 file changed, 463 insertions(+) create mode 100644 src-tauri/src/podman_desktop.rs diff --git a/src-tauri/src/podman_desktop.rs b/src-tauri/src/podman_desktop.rs new file mode 100644 index 000000000..b4aa5efe6 --- /dev/null +++ b/src-tauri/src/podman_desktop.rs @@ -0,0 +1,463 @@ +//! Desktop-safe projection of read-only Podman reclaim evidence. +//! +//! The headless `podman_reclaim` module intentionally gathers more local detail than the +//! desktop needs. This module converts that report into a bounded, privacy-safe contract +//! that contains measurements and stable issue codes, but never machine names, paths, +//! image identifiers, tags, or shell command text. + +use crate::podman_reclaim::{ + probe_podman_reclaim, PodmanRecommendedActionKind, PodmanReclaimPlan, + DEFAULT_PODMAN_MACHINE, DEFAULT_PROBE_TIMEOUT, +}; +use serde::Serialize; +use std::path::Path; + +/// Stable schema identifier for the desktop-safe Podman evidence response. +pub const PODMAN_DESKTOP_SCHEMA_KIND: &str = "disksage.podman-desktop-evidence"; + +/// Capacity observations displayed independently so logical size is never confused with +/// host allocation or verified physical reclaimability. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PodmanDesktopCapacityEvidence { + /// Podman machine disk capacity configured by the operator, when available. + pub configured_disk_bytes: Option, + /// Logical length of the VM raw image file, when available. + pub raw_logical_bytes: Option, + /// Host blocks currently allocated to the VM raw image, when supported by the host. + pub host_allocated_bytes: Option, + /// Total bytes reported by the guest root filesystem. + pub guest_total_bytes: Option, + /// Used bytes reported by the guest root filesystem. + pub guest_used_bytes: Option, + /// Available bytes reported by the guest root filesystem. + pub guest_available_bytes: Option, + /// Bytes Podman reports as allocated to its graph root inside the guest. + pub graph_root_allocated_bytes: Option, + /// Bytes Podman reports as used in its graph root inside the guest. + pub graph_root_used_bytes: Option, +} + +/// Logical cleanup candidates reported by Podman without exposing local identifiers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PodmanDesktopCandidateEvidence { + /// Logical image candidate bytes reported by `podman system df`. + pub image_candidate_bytes: Option, + /// Logical stopped-container candidate bytes reported by `podman system df`. + pub stopped_container_candidate_bytes: Option, + /// Logical local-volume candidate bytes reported by `podman system df`. + pub volume_candidate_bytes: Option, + /// Count of exact image records with no container references. + pub unused_image_records: Option, + /// Count of stopped containers observed in the Podman store. + pub stopped_container_records: Option, + /// SHA-256 commitment to exact unused image identifiers, tags, and sizes. + pub image_candidate_set_sha256: Option, +} + +/// Separate review boundaries for image, stopped-container, and volume decisions. +/// +/// These booleans are advisory only. They do not authorize or execute any mutation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PodmanDesktopReviewBoundaries { + /// Whether image candidates require an independent human review decision. + pub image_review_required: bool, + /// Whether stopped-container candidates require an independent human review decision. + pub stopped_container_review_required: bool, + /// Whether volume candidates require an independent human review decision. + pub volume_review_required: bool, +} + +/// Privacy-safe, read-only Podman evidence returned to the desktop frontend. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PodmanDesktopEvidence { + /// Stable schema identifier used by frontend validation. + pub schema_kind: &'static str, + /// Schema version for compatibility checks. + pub schema_version: u32, + /// Operating-system family that produced the evidence. + pub platform: &'static str, + /// True only when the headless probe is complete and the candidate fingerprint is valid. + pub evidence_complete: bool, + /// Bounded probe duration in milliseconds. + pub elapsed_ms: u64, + /// Capacity observations kept in distinct semantic categories. + pub capacity: PodmanDesktopCapacityEvidence, + /// Logical candidate observations kept separate by Podman object class. + pub candidates: PodmanDesktopCandidateEvidence, + /// Separate human-review boundaries for images, stopped containers, and volumes. + pub review_boundaries: PodmanDesktopReviewBoundaries, + /// Verified host physical reclaimability; intentionally `None` until before/after proof exists. + pub physically_reclaimable_bytes: Option, + /// Sum of Podman-reported logical candidate bytes, not physical reclaim proof. + pub podman_reported_reclaimable_bytes: Option, + /// Observed host-allocation minus guest-used gap, not physical reclaim proof. + pub raw_allocated_minus_guest_used_bytes: Option, + /// Stable assessment status such as `unverified`. + pub assessment_status: String, + /// Stable, non-sensitive assessment reason codes. + pub reason_codes: Vec, + /// Stable, non-sensitive probe issue codes with dynamic details removed. + pub issue_codes: Vec, + /// User-facing safety statements that define the evidence boundary. + pub notices: Vec, +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +fn stable_issue_code(value: &str) -> String { + value.split(':').next().unwrap_or("podman-evidence-error").to_string() +} + +fn has_action(plan: &PodmanReclaimPlan, kind: PodmanRecommendedActionKind) -> bool { + plan.assessment + .recommended_actions + .iter() + .any(|action| action.kind == kind && action.requires_human_approval) +} + +/// Convert a detailed headless Podman plan into the desktop-safe contract. +/// +/// The conversion removes machine names, all local paths, graph-root locations, image IDs, +/// tags, command output, and dynamic error details. Invalid candidate fingerprints fail +/// closed by clearing the fingerprint and marking the response incomplete. +pub fn redact_podman_reclaim_plan(plan: PodmanReclaimPlan) -> PodmanDesktopEvidence { + let mut issue_codes = plan + .issues + .iter() + .map(|issue| stable_issue_code(issue)) + .collect::>(); + + let candidate_fingerprint = plan + .unused_images + .as_ref() + .map(|images| images.candidate_set_sha256.clone()); + let fingerprint_valid = candidate_fingerprint + .as_deref() + .is_none_or(valid_sha256); + if !fingerprint_valid { + issue_codes.push("podman-desktop-invalid-candidate-fingerprint".to_string()); + } + issue_codes.sort(); + issue_codes.dedup(); + + let capacity = PodmanDesktopCapacityEvidence { + configured_disk_bytes: plan + .machine + .as_ref() + .and_then(|machine| machine.configured_disk_bytes), + raw_logical_bytes: plan.raw_image.as_ref().map(|image| image.logical_bytes), + host_allocated_bytes: plan + .raw_image + .as_ref() + .and_then(|image| image.allocated_bytes), + guest_total_bytes: plan + .guest_filesystem + .as_ref() + .map(|guest| guest.total_bytes), + guest_used_bytes: plan + .guest_filesystem + .as_ref() + .map(|guest| guest.used_bytes), + guest_available_bytes: plan + .guest_filesystem + .as_ref() + .map(|guest| guest.available_bytes), + graph_root_allocated_bytes: plan + .store + .as_ref() + .map(|store| store.graph_root_allocated_bytes), + graph_root_used_bytes: plan + .store + .as_ref() + .map(|store| store.graph_root_used_bytes), + }; + + let candidates = PodmanDesktopCandidateEvidence { + image_candidate_bytes: plan + .system_df + .as_ref() + .map(|evidence| evidence.images.reclaimable_bytes), + stopped_container_candidate_bytes: plan + .system_df + .as_ref() + .map(|evidence| evidence.containers.reclaimable_bytes), + volume_candidate_bytes: plan + .system_df + .as_ref() + .map(|evidence| evidence.local_volumes.reclaimable_bytes), + unused_image_records: plan + .unused_images + .as_ref() + .map(|images| images.unused_records), + stopped_container_records: plan + .store + .as_ref() + .map(|store| store.containers_stopped), + image_candidate_set_sha256: candidate_fingerprint.filter(|_| fingerprint_valid), + }; + + PodmanDesktopEvidence { + schema_kind: PODMAN_DESKTOP_SCHEMA_KIND, + schema_version: 1, + platform: plan.platform, + evidence_complete: plan.evidence_complete && fingerprint_valid, + elapsed_ms: plan.elapsed_ms, + capacity, + candidates, + review_boundaries: PodmanDesktopReviewBoundaries { + image_review_required: has_action( + &plan, + PodmanRecommendedActionKind::ReviewUnusedImages, + ), + stopped_container_review_required: has_action( + &plan, + PodmanRecommendedActionKind::ReviewStoppedContainers, + ), + volume_review_required: has_action( + &plan, + PodmanRecommendedActionKind::ReviewUnusedVolumes, + ), + }, + physically_reclaimable_bytes: plan.assessment.physically_reclaimable_bytes, + podman_reported_reclaimable_bytes: plan.assessment.podman_reported_reclaimable_bytes, + raw_allocated_minus_guest_used_bytes: plan + .assessment + .raw_allocated_minus_guest_used_bytes, + assessment_status: plan.assessment.status, + reason_codes: plan.assessment.reason_codes, + issue_codes, + notices: vec![ + "Podman-reported logical candidates are not verified host physical reclaimability." + .to_string(), + "This desktop surface exposes no prune, remove, machine lifecycle, TRIM, or raw-image mutation command." + .to_string(), + ], + } +} + +/// Run the bounded read-only Podman probe and return only the desktop-safe projection. +/// +/// The command passes an argument vector directly to `std::process::Command` through the +/// headless probe. It never constructs a shell command and never executes a mutation. +#[cfg(not(coverage))] +#[tauri::command] +pub fn inspect_podman_reclaim() -> PodmanDesktopEvidence { + redact_podman_reclaim_plan(probe_podman_reclaim( + Path::new("podman"), + DEFAULT_PODMAN_MACHINE, + DEFAULT_PROBE_TIMEOUT, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::podman_reclaim::{ + GuestFilesystemEvidence, PodmanMachineEvidence, PodmanRecommendedAction, + PodmanReclaimAssessment, PodmanStoreEvidence, PodmanSystemDfCategoryEvidence, + PodmanSystemDfEvidence, PodmanUnusedImageEvidence, RawImageEvidence, + PODMAN_RECLAIM_SCHEMA_KIND, + }; + + fn category(reclaimable_bytes: u64) -> PodmanSystemDfCategoryEvidence { + PodmanSystemDfCategoryEvidence { + total: 2, + active: 1, + size_bytes: reclaimable_bytes.saturating_add(10), + reclaimable_bytes, + } + } + + fn complete_plan() -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: true, + elapsed_ms: 17, + machine: Some(PodmanMachineEvidence { + name: "private-machine".to_string(), + state: "running".to_string(), + configured_disk_bytes: Some(1000), + }), + raw_image: Some(RawImageEvidence { + path: "/Users/private/.local/share/private-machine.raw".to_string(), + logical_bytes: 900, + allocated_bytes: Some(700), + }), + guest_filesystem: Some(GuestFilesystemEvidence { + total_bytes: 800, + used_bytes: 500, + available_bytes: 300, + }), + store: Some(PodmanStoreEvidence { + graph_root: "/var/home/private/containers".to_string(), + graph_root_allocated_bytes: 600, + graph_root_used_bytes: 450, + images: 4, + containers_total: 3, + containers_running: 1, + containers_stopped: 2, + }), + system_df: Some(PodmanSystemDfEvidence { + images: category(200), + containers: category(30), + local_volumes: category(70), + }), + unused_images: Some(PodmanUnusedImageEvidence { + total_records: 4, + referenced_records: 2, + unused_records: 2, + unused_untagged_records: 1, + unused_tagged_records: 1, + candidate_record_size_sum: 200, + candidate_set_sha256: "a".repeat(64), + }), + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: None, + podman_reported_reclaimable_bytes: Some(300), + raw_allocated_minus_guest_used_bytes: Some(200), + status: "unverified".to_string(), + reason_codes: vec!["host-physical-reclaim-unverified".to_string()], + recommended_actions: vec![ + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewUnusedImages, + requires_human_approval: true, + rationale: "image review".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewStoppedContainers, + requires_human_approval: true, + rationale: "container review".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewUnusedVolumes, + requires_human_approval: true, + rationale: "volume review".to_string(), + }, + ], + }, + issues: vec![], + } + } + + #[test] + fn projection_keeps_measurements_separate_and_removes_private_context() { + let evidence = redact_podman_reclaim_plan(complete_plan()); + assert!(evidence.evidence_complete); + assert_eq!(evidence.capacity.configured_disk_bytes, Some(1000)); + assert_eq!(evidence.capacity.raw_logical_bytes, Some(900)); + assert_eq!(evidence.capacity.host_allocated_bytes, Some(700)); + assert_eq!(evidence.capacity.guest_used_bytes, Some(500)); + assert_eq!(evidence.candidates.image_candidate_bytes, Some(200)); + assert_eq!( + evidence.candidates.stopped_container_candidate_bytes, + Some(30) + ); + assert_eq!(evidence.candidates.volume_candidate_bytes, Some(70)); + assert_eq!( + evidence.candidates.image_candidate_set_sha256, + Some("a".repeat(64)) + ); + let json = serde_json::to_string(&evidence).unwrap(); + assert!(!json.contains("private-machine")); + assert!(!json.contains("/Users/private")); + assert!(!json.contains("/var/home/private")); + assert!(!json.contains("graph_root")); + } + + #[test] + fn image_container_and_volume_reviews_remain_separate() { + let evidence = redact_podman_reclaim_plan(complete_plan()); + assert!(evidence.review_boundaries.image_review_required); + assert!(evidence.review_boundaries.stopped_container_review_required); + assert!(evidence.review_boundaries.volume_review_required); + + let mut plan = complete_plan(); + plan.assessment.recommended_actions = vec![PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::InvestigateApi, + requires_human_approval: false, + rationale: "diagnostic only".to_string(), + }]; + let evidence = redact_podman_reclaim_plan(plan); + assert!(!evidence.review_boundaries.image_review_required); + assert!(!evidence.review_boundaries.stopped_container_review_required); + assert!(!evidence.review_boundaries.volume_review_required); + } + + #[test] + fn dynamic_issue_details_are_redacted_and_deduplicated() { + let mut plan = complete_plan(); + plan.evidence_complete = false; + plan.issues = vec![ + "podman-info-failed:/Users/alice/private.sock".to_string(), + "podman-info-failed:duplicate detail".to_string(), + "podman-images-timeout".to_string(), + ]; + let evidence = redact_podman_reclaim_plan(plan); + assert!(!evidence.evidence_complete); + assert_eq!( + evidence.issue_codes, + vec![ + "podman-images-timeout".to_string(), + "podman-info-failed".to_string(), + ] + ); + assert!(!serde_json::to_string(&evidence) + .unwrap() + .contains("Users/alice")); + } + + #[test] + fn invalid_fingerprint_fails_closed_without_hiding_other_evidence() { + let mut plan = complete_plan(); + plan.unused_images.as_mut().unwrap().candidate_set_sha256 = "BAD".to_string(); + let evidence = redact_podman_reclaim_plan(plan); + assert!(!evidence.evidence_complete); + assert_eq!(evidence.candidates.image_candidate_set_sha256, None); + assert!(evidence + .issue_codes + .contains(&"podman-desktop-invalid-candidate-fingerprint".to_string())); + assert_eq!(evidence.candidates.image_candidate_bytes, Some(200)); + } + + #[test] + fn absent_optional_evidence_stays_unknown_instead_of_becoming_zero() { + let mut plan = complete_plan(); + plan.machine = None; + plan.raw_image = None; + plan.guest_filesystem = None; + plan.store = None; + plan.system_df = None; + plan.unused_images = None; + plan.evidence_complete = false; + let evidence = redact_podman_reclaim_plan(plan); + assert_eq!(evidence.capacity.configured_disk_bytes, None); + assert_eq!(evidence.capacity.raw_logical_bytes, None); + assert_eq!(evidence.capacity.host_allocated_bytes, None); + assert_eq!(evidence.capacity.guest_total_bytes, None); + assert_eq!(evidence.capacity.guest_used_bytes, None); + assert_eq!(evidence.capacity.guest_available_bytes, None); + assert_eq!(evidence.capacity.graph_root_allocated_bytes, None); + assert_eq!(evidence.capacity.graph_root_used_bytes, None); + assert_eq!(evidence.candidates.image_candidate_bytes, None); + assert_eq!(evidence.candidates.stopped_container_candidate_bytes, None); + assert_eq!(evidence.candidates.volume_candidate_bytes, None); + assert_eq!(evidence.candidates.unused_image_records, None); + assert_eq!(evidence.candidates.stopped_container_records, None); + assert_eq!(evidence.candidates.image_candidate_set_sha256, None); + } + + #[test] + fn issue_code_fallback_is_stable_for_empty_detail() { + assert_eq!(stable_issue_code(""), ""); + assert!(valid_sha256(&"0".repeat(64))); + assert!(!valid_sha256(&"A".repeat(64))); + assert!(!valid_sha256("short")); + } +} From 4ee0c0cb73a4e2255a46cebb17ac9a8ec650008a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:11:03 +0900 Subject: [PATCH 02/66] feat: register read-only Podman desktop evidence command --- src-tauri/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 81f6e2574..d351c693a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -55,6 +55,8 @@ pub mod multipart_archive; pub mod naruon_capacity; pub mod naruon_cloud_copy_readiness; pub mod naruon_lineage; +/// Privacy-safe desktop projection of read-only Podman reclaim evidence. +pub mod podman_desktop; /// Read-only, fail-closed Podman VM/store reclaim evidence. pub mod podman_reclaim; pub mod provider_api_client; @@ -121,7 +123,8 @@ pub fn run() { commands::copy_cloud_candidate, commands::adopt_existing_cloud_candidate, commands::attest_cloud_copy, - commands::trash_verified_cloud_source + commands::trash_verified_cloud_source, + podman_desktop::inspect_podman_reclaim ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); From d213c33c512fc7b9c8dcc22ee4b0ed43e550eef5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:11:51 +0900 Subject: [PATCH 03/66] test: add fail-closed Podman desktop API contract --- src/lib/podmanEvidence.ts | 255 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 src/lib/podmanEvidence.ts diff --git a/src/lib/podmanEvidence.ts b/src/lib/podmanEvidence.ts new file mode 100644 index 000000000..f3bc75ab4 --- /dev/null +++ b/src/lib/podmanEvidence.ts @@ -0,0 +1,255 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** Stable schema kind emitted by the Rust desktop projection. */ +export const PODMAN_DESKTOP_SCHEMA_KIND = "disksage.podman-desktop-evidence"; + +/** Nullable byte value used when an observation could not be collected. */ +export type OptionalBytes = number | null; + +/** Capacity observations whose meanings must remain visually separate. */ +export interface PodmanDesktopCapacityEvidence { + configured_disk_bytes: OptionalBytes; + raw_logical_bytes: OptionalBytes; + host_allocated_bytes: OptionalBytes; + guest_total_bytes: OptionalBytes; + guest_used_bytes: OptionalBytes; + guest_available_bytes: OptionalBytes; + graph_root_allocated_bytes: OptionalBytes; + graph_root_used_bytes: OptionalBytes; +} + +/** Logical Podman candidates that are not verified host physical reclaimability. */ +export interface PodmanDesktopCandidateEvidence { + image_candidate_bytes: OptionalBytes; + stopped_container_candidate_bytes: OptionalBytes; + volume_candidate_bytes: OptionalBytes; + unused_image_records: number | null; + stopped_container_records: number | null; + image_candidate_set_sha256: string | null; +} + +/** Separate human-review boundaries for each Podman object class. */ +export interface PodmanDesktopReviewBoundaries { + image_review_required: boolean; + stopped_container_review_required: boolean; + volume_review_required: boolean; +} + +/** Privacy-safe, read-only Podman evidence returned by the Tauri command. */ +export interface PodmanDesktopEvidence { + schema_kind: typeof PODMAN_DESKTOP_SCHEMA_KIND; + schema_version: 1; + platform: string; + evidence_complete: boolean; + elapsed_ms: number; + capacity: PodmanDesktopCapacityEvidence; + candidates: PodmanDesktopCandidateEvidence; + review_boundaries: PodmanDesktopReviewBoundaries; + physically_reclaimable_bytes: OptionalBytes; + podman_reported_reclaimable_bytes: OptionalBytes; + raw_allocated_minus_guest_used_bytes: OptionalBytes; + assessment_status: string; + reason_codes: string[]; + issue_codes: string[]; + notices: string[]; +} + +/** Display model used by the Svelte component and its headless behavior tests. */ +export interface PodmanEvidenceView { + completeness_label: string; + completeness_tone: "complete" | "partial"; + physical_reclaim_label: string; + image_review_label: string; + container_review_label: string; + volume_review_label: string; + has_issues: boolean; +} + +type InvokeFunction = (command: string) => Promise; +type JsonRecord = Record; + +function record(value: unknown, label: string): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`invalid-${label}`); + } + return value as JsonRecord; +} + +function stringValue(value: unknown, label: string): string { + if (typeof value !== "string") throw new Error(`invalid-${label}`); + return value; +} + +function booleanValue(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new Error(`invalid-${label}`); + return value; +} + +function unsignedInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`invalid-${label}`); + } + return value; +} + +function optionalUnsignedInteger(value: unknown, label: string): number | null { + return value === null ? null : unsignedInteger(value, label); +} + +function stringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { + throw new Error(`invalid-${label}`); + } + return [...value]; +} + +function sha256OrNull(value: unknown): string | null { + if (value === null) return null; + const fingerprint = stringValue(value, "image-candidate-set-sha256"); + if (!/^[0-9a-f]{64}$/.test(fingerprint)) { + throw new Error("invalid-image-candidate-set-sha256"); + } + return fingerprint; +} + +function parseCapacity(value: unknown): PodmanDesktopCapacityEvidence { + const capacity = record(value, "podman-capacity"); + return { + configured_disk_bytes: optionalUnsignedInteger( + capacity.configured_disk_bytes, + "configured-disk-bytes", + ), + raw_logical_bytes: optionalUnsignedInteger(capacity.raw_logical_bytes, "raw-logical-bytes"), + host_allocated_bytes: optionalUnsignedInteger( + capacity.host_allocated_bytes, + "host-allocated-bytes", + ), + guest_total_bytes: optionalUnsignedInteger(capacity.guest_total_bytes, "guest-total-bytes"), + guest_used_bytes: optionalUnsignedInteger(capacity.guest_used_bytes, "guest-used-bytes"), + guest_available_bytes: optionalUnsignedInteger( + capacity.guest_available_bytes, + "guest-available-bytes", + ), + graph_root_allocated_bytes: optionalUnsignedInteger( + capacity.graph_root_allocated_bytes, + "graph-root-allocated-bytes", + ), + graph_root_used_bytes: optionalUnsignedInteger( + capacity.graph_root_used_bytes, + "graph-root-used-bytes", + ), + }; +} + +function parseCandidates(value: unknown): PodmanDesktopCandidateEvidence { + const candidates = record(value, "podman-candidates"); + return { + image_candidate_bytes: optionalUnsignedInteger( + candidates.image_candidate_bytes, + "image-candidate-bytes", + ), + stopped_container_candidate_bytes: optionalUnsignedInteger( + candidates.stopped_container_candidate_bytes, + "stopped-container-candidate-bytes", + ), + volume_candidate_bytes: optionalUnsignedInteger( + candidates.volume_candidate_bytes, + "volume-candidate-bytes", + ), + unused_image_records: optionalUnsignedInteger( + candidates.unused_image_records, + "unused-image-records", + ), + stopped_container_records: optionalUnsignedInteger( + candidates.stopped_container_records, + "stopped-container-records", + ), + image_candidate_set_sha256: sha256OrNull(candidates.image_candidate_set_sha256), + }; +} + +function parseReviewBoundaries(value: unknown): PodmanDesktopReviewBoundaries { + const boundaries = record(value, "podman-review-boundaries"); + return { + image_review_required: booleanValue( + boundaries.image_review_required, + "image-review-required", + ), + stopped_container_review_required: booleanValue( + boundaries.stopped_container_review_required, + "stopped-container-review-required", + ), + volume_review_required: booleanValue( + boundaries.volume_review_required, + "volume-review-required", + ), + }; +} + +/** Parse the Rust response and fail closed on schema, type, range, or fingerprint drift. */ +export function parsePodmanDesktopEvidence(value: unknown): PodmanDesktopEvidence { + const evidence = record(value, "podman-desktop-evidence"); + if (evidence.schema_kind !== PODMAN_DESKTOP_SCHEMA_KIND) { + throw new Error("unsupported-podman-desktop-schema-kind"); + } + if (evidence.schema_version !== 1) { + throw new Error("unsupported-podman-desktop-schema-version"); + } + return { + schema_kind: PODMAN_DESKTOP_SCHEMA_KIND, + schema_version: 1, + platform: stringValue(evidence.platform, "platform"), + evidence_complete: booleanValue(evidence.evidence_complete, "evidence-complete"), + elapsed_ms: unsignedInteger(evidence.elapsed_ms, "elapsed-ms"), + capacity: parseCapacity(evidence.capacity), + candidates: parseCandidates(evidence.candidates), + review_boundaries: parseReviewBoundaries(evidence.review_boundaries), + physically_reclaimable_bytes: optionalUnsignedInteger( + evidence.physically_reclaimable_bytes, + "physically-reclaimable-bytes", + ), + podman_reported_reclaimable_bytes: optionalUnsignedInteger( + evidence.podman_reported_reclaimable_bytes, + "podman-reported-reclaimable-bytes", + ), + raw_allocated_minus_guest_used_bytes: optionalUnsignedInteger( + evidence.raw_allocated_minus_guest_used_bytes, + "raw-allocated-minus-guest-used-bytes", + ), + assessment_status: stringValue(evidence.assessment_status, "assessment-status"), + reason_codes: stringArray(evidence.reason_codes, "reason-codes"), + issue_codes: stringArray(evidence.issue_codes, "issue-codes"), + notices: stringArray(evidence.notices, "notices"), + }; +} + +/** Invoke the read-only Tauri command and validate the returned contract. */ +export async function loadPodmanEvidence( + invokeFunction: InvokeFunction = invoke, +): Promise { + return parsePodmanDesktopEvidence( + await invokeFunction("inspect_podman_reclaim"), + ); +} + +/** Derive stable user-facing state labels without granting any cleanup authority. */ +export function podmanEvidenceView(evidence: PodmanDesktopEvidence): PodmanEvidenceView { + return { + completeness_label: evidence.evidence_complete ? "증거 완전" : "부분 증거", + completeness_tone: evidence.evidence_complete ? "complete" : "partial", + physical_reclaim_label: + evidence.physically_reclaimable_bytes === null + ? "검증되지 않음" + : `${evidence.physically_reclaimable_bytes} bytes`, + image_review_label: evidence.review_boundaries.image_review_required + ? "이미지 별도 검토 필요" + : "이미지 검토 신호 없음", + container_review_label: evidence.review_boundaries.stopped_container_review_required + ? "중지 컨테이너 별도 검토 필요" + : "중지 컨테이너 검토 신호 없음", + volume_review_label: evidence.review_boundaries.volume_review_required + ? "볼륨 별도 검토 필요" + : "볼륨 검토 신호 없음", + has_issues: evidence.issue_codes.length > 0, + }; +} From 2032f115fce6074a220e84b5648298c89a3cc587 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:12:44 +0900 Subject: [PATCH 04/66] test: cover Podman desktop evidence API and view behavior --- src/lib/podmanEvidence.test.ts | 255 +++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 src/lib/podmanEvidence.test.ts diff --git a/src/lib/podmanEvidence.test.ts b/src/lib/podmanEvidence.test.ts new file mode 100644 index 000000000..f2004745f --- /dev/null +++ b/src/lib/podmanEvidence.test.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); + +import { + PODMAN_DESKTOP_SCHEMA_KIND, + loadPodmanEvidence, + parsePodmanDesktopEvidence, + podmanEvidenceView, + type PodmanDesktopEvidence, +} from "./podmanEvidence"; + +function fixture(): Record { + return { + schema_kind: PODMAN_DESKTOP_SCHEMA_KIND, + schema_version: 1, + platform: "macos", + evidence_complete: true, + elapsed_ms: 17, + capacity: { + configured_disk_bytes: 1000, + raw_logical_bytes: 900, + host_allocated_bytes: 700, + guest_total_bytes: 800, + guest_used_bytes: 500, + guest_available_bytes: 300, + graph_root_allocated_bytes: 600, + graph_root_used_bytes: 450, + }, + candidates: { + image_candidate_bytes: 200, + stopped_container_candidate_bytes: 30, + volume_candidate_bytes: 70, + unused_image_records: 2, + stopped_container_records: 2, + image_candidate_set_sha256: "a".repeat(64), + }, + review_boundaries: { + image_review_required: true, + stopped_container_review_required: true, + volume_review_required: true, + }, + physically_reclaimable_bytes: null, + podman_reported_reclaimable_bytes: 300, + raw_allocated_minus_guest_used_bytes: 200, + assessment_status: "unverified", + reason_codes: ["host-physical-reclaim-unverified"], + issue_codes: ["partial-evidence"], + notices: ["read only"], + }; +} + +function cloneFixture(): Record { + return JSON.parse(JSON.stringify(fixture())); +} + +beforeEach(() => { + invokeMock.mockReset(); +}); + +describe("parsePodmanDesktopEvidence", () => { + it("accepts the complete privacy-safe schema", () => { + const parsed = parsePodmanDesktopEvidence(fixture()); + expect(parsed.schema_kind).toBe(PODMAN_DESKTOP_SCHEMA_KIND); + expect(parsed.capacity.host_allocated_bytes).toBe(700); + expect(parsed.candidates.image_candidate_set_sha256).toBe("a".repeat(64)); + expect(parsed.reason_codes).toEqual(["host-physical-reclaim-unverified"]); + }); + + it("preserves unknown observations as null", () => { + const value = cloneFixture(); + for (const key of Object.keys(value.capacity)) value.capacity[key] = null; + for (const key of [ + "image_candidate_bytes", + "stopped_container_candidate_bytes", + "volume_candidate_bytes", + "unused_image_records", + "stopped_container_records", + "image_candidate_set_sha256", + ]) { + value.candidates[key] = null; + } + value.physically_reclaimable_bytes = null; + value.podman_reported_reclaimable_bytes = null; + value.raw_allocated_minus_guest_used_bytes = null; + const parsed = parsePodmanDesktopEvidence(value); + expect(Object.values(parsed.capacity).every((entry) => entry === null)).toBe(true); + expect(Object.values(parsed.candidates).every((entry) => entry === null)).toBe(true); + }); + + it.each([ + [null, "invalid-podman-desktop-evidence"], + [[], "invalid-podman-desktop-evidence"], + ["bad", "invalid-podman-desktop-evidence"], + ])("rejects a non-record response %#", (value, message) => { + expect(() => parsePodmanDesktopEvidence(value)).toThrow(message); + }); + + it("rejects schema drift", () => { + const wrongKind = cloneFixture(); + wrongKind.schema_kind = "other"; + expect(() => parsePodmanDesktopEvidence(wrongKind)).toThrow( + "unsupported-podman-desktop-schema-kind", + ); + const wrongVersion = cloneFixture(); + wrongVersion.schema_version = 2; + expect(() => parsePodmanDesktopEvidence(wrongVersion)).toThrow( + "unsupported-podman-desktop-schema-version", + ); + }); + + it.each([ + ["platform", 1, "invalid-platform"], + ["evidence_complete", "yes", "invalid-evidence-complete"], + ["elapsed_ms", "17", "invalid-elapsed-ms"], + ["elapsed_ms", 1.5, "invalid-elapsed-ms"], + ["elapsed_ms", -1, "invalid-elapsed-ms"], + ["assessment_status", false, "invalid-assessment-status"], + ["reason_codes", "bad", "invalid-reason-codes"], + ["reason_codes", ["ok", 2], "invalid-reason-codes"], + ["issue_codes", "bad", "invalid-issue-codes"], + ["notices", "bad", "invalid-notices"], + ])("rejects invalid top-level field %s", (field, value, message) => { + const invalid = cloneFixture(); + invalid[field] = value; + expect(() => parsePodmanDesktopEvidence(invalid)).toThrow(message); + }); + + it("rejects invalid nested records", () => { + const capacity = cloneFixture(); + capacity.capacity = []; + expect(() => parsePodmanDesktopEvidence(capacity)).toThrow("invalid-podman-capacity"); + + const candidates = cloneFixture(); + candidates.candidates = null; + expect(() => parsePodmanDesktopEvidence(candidates)).toThrow("invalid-podman-candidates"); + + const boundaries = cloneFixture(); + boundaries.review_boundaries = "bad"; + expect(() => parsePodmanDesktopEvidence(boundaries)).toThrow( + "invalid-podman-review-boundaries", + ); + }); + + it.each([ + ["configured_disk_bytes", -1, "invalid-configured-disk-bytes"], + ["raw_logical_bytes", "1", "invalid-raw-logical-bytes"], + ["host_allocated_bytes", 1.2, "invalid-host-allocated-bytes"], + ["guest_total_bytes", -1, "invalid-guest-total-bytes"], + ["guest_used_bytes", "1", "invalid-guest-used-bytes"], + ["guest_available_bytes", 1.2, "invalid-guest-available-bytes"], + ["graph_root_allocated_bytes", -1, "invalid-graph-root-allocated-bytes"], + ["graph_root_used_bytes", "1", "invalid-graph-root-used-bytes"], + ])("rejects invalid capacity field %s", (field, value, message) => { + const invalid = cloneFixture(); + invalid.capacity[field] = value; + expect(() => parsePodmanDesktopEvidence(invalid)).toThrow(message); + }); + + it.each([ + ["image_candidate_bytes", -1, "invalid-image-candidate-bytes"], + ["stopped_container_candidate_bytes", "1", "invalid-stopped-container-candidate-bytes"], + ["volume_candidate_bytes", 1.2, "invalid-volume-candidate-bytes"], + ["unused_image_records", -1, "invalid-unused-image-records"], + ["stopped_container_records", "1", "invalid-stopped-container-records"], + ])("rejects invalid candidate field %s", (field, value, message) => { + const invalid = cloneFixture(); + invalid.candidates[field] = value; + expect(() => parsePodmanDesktopEvidence(invalid)).toThrow(message); + }); + + it("rejects malformed or non-string candidate fingerprints", () => { + const malformed = cloneFixture(); + malformed.candidates.image_candidate_set_sha256 = "BAD"; + expect(() => parsePodmanDesktopEvidence(malformed)).toThrow( + "invalid-image-candidate-set-sha256", + ); + const wrongType = cloneFixture(); + wrongType.candidates.image_candidate_set_sha256 = 1; + expect(() => parsePodmanDesktopEvidence(wrongType)).toThrow( + "invalid-image-candidate-set-sha256", + ); + }); + + it.each([ + ["image_review_required", "yes", "invalid-image-review-required"], + ["stopped_container_review_required", 1, "invalid-stopped-container-review-required"], + ["volume_review_required", null, "invalid-volume-review-required"], + ])("rejects invalid review boundary %s", (field, value, message) => { + const invalid = cloneFixture(); + invalid.review_boundaries[field] = value; + expect(() => parsePodmanDesktopEvidence(invalid)).toThrow(message); + }); + + it.each([ + ["physically_reclaimable_bytes", -1, "invalid-physically-reclaimable-bytes"], + ["podman_reported_reclaimable_bytes", "1", "invalid-podman-reported-reclaimable-bytes"], + ["raw_allocated_minus_guest_used_bytes", 1.5, "invalid-raw-allocated-minus-guest-used-bytes"], + ])("rejects invalid assessment byte field %s", (field, value, message) => { + const invalid = cloneFixture(); + invalid[field] = value; + expect(() => parsePodmanDesktopEvidence(invalid)).toThrow(message); + }); +}); + +describe("loadPodmanEvidence", () => { + it("uses the registered read-only command by default", async () => { + invokeMock.mockResolvedValue(fixture()); + await expect(loadPodmanEvidence()).resolves.toMatchObject({ schema_version: 1 }); + expect(invokeMock).toHaveBeenCalledWith("inspect_podman_reclaim"); + }); + + it("supports an injected invoker for deterministic contract tests", async () => { + const injected = vi.fn().mockResolvedValue(fixture()); + await expect(loadPodmanEvidence(injected)).resolves.toMatchObject({ platform: "macos" }); + expect(injected).toHaveBeenCalledWith("inspect_podman_reclaim"); + }); +}); + +describe("podmanEvidenceView", () => { + it("labels complete evidence while keeping physical reclaim unknown", () => { + const evidence = parsePodmanDesktopEvidence(fixture()); + expect(podmanEvidenceView(evidence)).toEqual({ + completeness_label: "증거 완전", + completeness_tone: "complete", + physical_reclaim_label: "검증되지 않음", + image_review_label: "이미지 별도 검토 필요", + container_review_label: "중지 컨테이너 별도 검토 필요", + volume_review_label: "볼륨 별도 검토 필요", + has_issues: true, + }); + }); + + it("labels partial evidence and keeps all review domains independent", () => { + const value = cloneFixture(); + value.evidence_complete = false; + value.physically_reclaimable_bytes = 12; + value.review_boundaries.image_review_required = false; + value.review_boundaries.stopped_container_review_required = false; + value.review_boundaries.volume_review_required = false; + value.issue_codes = []; + const evidence = parsePodmanDesktopEvidence(value) as PodmanDesktopEvidence; + expect(podmanEvidenceView(evidence)).toEqual({ + completeness_label: "부분 증거", + completeness_tone: "partial", + physical_reclaim_label: "12 bytes", + image_review_label: "이미지 검토 신호 없음", + container_review_label: "중지 컨테이너 검토 신호 없음", + volume_review_label: "볼륨 검토 신호 없음", + has_issues: false, + }); + }); +}); From 6e9d97a7db702cddcc6de73af3ed8adf85d68ab3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:13:34 +0900 Subject: [PATCH 05/66] feat: render privacy-safe Podman reclaim evidence --- src/lib/PodmanEvidence.svelte | 206 ++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 src/lib/PodmanEvidence.svelte diff --git a/src/lib/PodmanEvidence.svelte b/src/lib/PodmanEvidence.svelte new file mode 100644 index 000000000..ba0696456 --- /dev/null +++ b/src/lib/PodmanEvidence.svelte @@ -0,0 +1,206 @@ + + +
+
+
+

Podman 저장소 증거

+

+ 읽기 전용 진단입니다. 이미지, 컨테이너, 볼륨을 삭제하거나 Podman 머신을 변경하지 않습니다. +

+
+ +
+ + {#if busy} +

Podman의 제한된 읽기 전용 증거를 수집하고 있습니다.

+ {/if} + + {#if error} + + {/if} + + {#if evidence && view} +
+ + {view.completeness_label} + + 호스트 물리 회수 가능량: {view.physical_reclaim_label} + 수집 시간: {evidence.elapsed_ms}ms +
+ +

+ Podman이 보고한 논리 후보는 호스트에서 실제로 회수될 물리 공간의 증명이 아닙니다. 실제 회수량은 별도의 전후 호스트 관측이 있어야 확정됩니다. +

+ +

서로 다른 용량 관측

+
+
+
설정된 머신 디스크
+
{optionalBytes(evidence.capacity.configured_disk_bytes)}
+
+
+
Raw 이미지 논리 크기
+
{optionalBytes(evidence.capacity.raw_logical_bytes)}
+
+
+
호스트 할당 블록
+
{optionalBytes(evidence.capacity.host_allocated_bytes)}
+
+
+
게스트 파일시스템 전체
+
{optionalBytes(evidence.capacity.guest_total_bytes)}
+
+
+
게스트 파일시스템 사용
+
{optionalBytes(evidence.capacity.guest_used_bytes)}
+
+
+
게스트 파일시스템 여유
+
{optionalBytes(evidence.capacity.guest_available_bytes)}
+
+
+
Podman graph root 할당
+
{optionalBytes(evidence.capacity.graph_root_allocated_bytes)}
+
+
+
Podman graph root 사용
+
{optionalBytes(evidence.capacity.graph_root_used_bytes)}
+
+
+
Raw 할당−게스트 사용 차이
+
{optionalBytes(evidence.raw_allocated_minus_guest_used_bytes)}
+
+
+
Podman 논리 후보 합계
+
{optionalBytes(evidence.podman_reported_reclaimable_bytes)}
+
+
+ +

분리된 검토 영역

+
+
+
이미지
+

{view.image_review_label}

+
+
논리 후보
{optionalBytes(evidence.candidates.image_candidate_bytes)}
+
참조 0 레코드
{optionalCount(evidence.candidates.unused_image_records)}
+
+
+
+
중지 컨테이너
+

{view.container_review_label}

+
+
논리 후보
{optionalBytes(evidence.candidates.stopped_container_candidate_bytes)}
+
중지 레코드
{optionalCount(evidence.candidates.stopped_container_records)}
+
+
+
+
로컬 볼륨
+

{view.volume_review_label}

+
+
논리 후보
{optionalBytes(evidence.candidates.volume_candidate_bytes)}
+
+
+
+ +

후보 집합 증거

+

+ 이미지 후보 집합 SHA-256: + {#if evidence.candidates.image_candidate_set_sha256} + {evidence.candidates.image_candidate_set_sha256} + {:else} + 관측되지 않음 + {/if} +

+ + {#if evidence.reason_codes.length > 0} +

판정 사유 코드

+
    + {#each evidence.reason_codes as reason (reason)} +
  • {reason}
  • + {/each} +
+ {/if} + + {#if view.has_issues} +

증거 누락·오류 코드

+
    + {#each evidence.issue_codes as issue (issue)} +
  • {issue}
  • + {/each} +
+ {/if} + +
    + {#each evidence.notices as notice (notice)} +
  • {notice}
  • + {/each} +
+ {/if} +
+ + From ebc3974d8099c4a1eebba1a26b5a818436ea1df4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:14:06 +0900 Subject: [PATCH 06/66] feat: surface Podman evidence in Cleanup --- src/lib/Cleanup.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/Cleanup.svelte b/src/lib/Cleanup.svelte index eceb302ec..1a9976c9f 100644 --- a/src/lib/Cleanup.svelte +++ b/src/lib/Cleanup.svelte @@ -4,6 +4,7 @@ import { verdictBadge } from "./verdictBadge"; import { confirm } from "@tauri-apps/plugin-dialog"; import GitWorktreeCleanup from "./GitWorktreeCleanup.svelte"; + import PodmanEvidence from "./PodmanEvidence.svelte"; let { scannedRoot }: { scannedRoot: string | null } = $props(); @@ -157,6 +158,7 @@ {/if} {/if} + From 0ba5cc7cd684e3dce7ce81bd11c31a965d2ea44a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:14:20 +0900 Subject: [PATCH 07/66] test: include Podman desktop contract in 100% coverage gate --- vitest.config.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index 198e3dcb8..ce8ad23f0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,11 +13,12 @@ export default defineConfig({ "src/lib/fmt.ts", "src/lib/dupeGuard.ts", "src/lib/verdictBadge.ts", + "src/lib/podmanEvidence.ts", ], reporter: ["text", "json", "json-summary"], - // ponytail: 위 include 5개 순수 로직 파일은 헤드리스로 완전 검증 가능하므로 - // 네 지표 모두 100%로 고정한다. 이 게이트는 scope를 넓히지 않는다 — - // Svelte 컴포넌트는 여전히 cargo test + 수동 체크리스트로 검증한다. + // ponytail: 위 include의 헤드리스 순수 로직/API 계약 파일은 완전 검증 가능하므로 + // 네 지표 모두 100%로 고정한다. Svelte 컴포넌트의 상태 분기는 같은 순수 view + // model을 통해 검증하고, 실제 렌더링은 build/svelte-check와 수동 체크리스트로 확인한다. thresholds: { statements: 100, branches: 100, From 59934ad51acaa6c6c90230b8d4101db8b68be947 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:14:41 +0900 Subject: [PATCH 08/66] docs: record Podman desktop evidence integration --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87e165659..a52dadcf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +### Added + +- Add a read-only Podman evidence panel to Cleanup that separately displays configured VM capacity, raw-image logical size, host allocation, guest filesystem observations, Podman store observations, image/stopped-container/volume logical candidates, evidence completeness, stable issue codes, and a redacted candidate-set fingerprint. +- Add a privacy-safe Tauri contract that removes machine names, local paths, graph-root locations, image identifiers, tags, command output, and dynamic error details before evidence reaches the desktop frontend. + ### Changed - Align the frontend toolchain on Vite 8.2 and `@sveltejs/vite-plugin-svelte` 7.2 so the declared peer dependency graph is installable and reproducible. - Declare the supported Node.js runtime floor as Node.js 20.19 or Node.js 22.12 and later, matching Vite 8 requirements. - Pin the primary test workflow to Node.js 20.19.0 so the minimum supported runtime is continuously verified. - Document the iCloud batch operation's local-only versus path-free shareable evidence boundary and map its fail-closed controls to NIST SP 800-53 Release 5.2.0, ISO/IEC 27040:2024, and primary secure-design literature with APA 7th references and deterministic documentation contract tests. +- Keep Podman image, stopped-container, and volume review boundaries independent and advisory; no candidate class grants authority to another class. ### Fixed @@ -21,3 +27,4 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Generate the npm lockfile in an exact-head validation job with repository contents read-only and dependency lifecycle scripts disabled, bind the artifact to SHA-256 evidence, and grant `contents: write` only to a separate publication job that verifies the same-run artifact and unchanged branch head before committing the lockfile. - Removed obsolete one-shot repair workflows and patch scripts so repository automation no longer retains dormant write-capable recovery paths. +- Keep the Podman desktop surface observation-only: it exposes no prune, remove, machine stop/start, VM deletion, TRIM, raw-image mutation, or shell-string construction path, and it never labels Podman logical candidates as verified host physical reclaimability. From 2b92e46e8ef296c5e5ae1a00ba76de70134011e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:15:21 +0900 Subject: [PATCH 09/66] docs: add Podman desktop evidence ADR --- docs/architecture/podman-desktop-evidence.md | 119 +++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/architecture/podman-desktop-evidence.md diff --git a/docs/architecture/podman-desktop-evidence.md b/docs/architecture/podman-desktop-evidence.md new file mode 100644 index 000000000..949d5ce3e --- /dev/null +++ b/docs/architecture/podman-desktop-evidence.md @@ -0,0 +1,119 @@ +# ADR: Privacy-safe Podman desktop evidence + +- **Status:** Proposed +- **Date:** 2026-08-05 +- **Decision owners:** DiskSage maintainers +- **Related issue:** #107 +- **Related headless contract:** #105 and `src-tauri/src/podman_reclaim.rs` + +## Context + +DiskSage already has a Rust-first, read-only Podman evidence probe that distinguishes VM configuration, raw-image logical size, host allocation, guest filesystem usage, Podman graph-root observations, and Podman-reported logical cleanup candidates. The desktop Cleanup experience previously had no supported way to inspect that evidence. + +The UI must not turn evidence into authority. Podman documents that image reclaimable values can overstate what a prune would actually free when layers are shared. DiskSage therefore treats all `podman system df` candidate values as logical review evidence rather than verified host physical reclaimability. + +The headless report also contains local-only details such as machine names, configuration paths, raw-image paths, graph-root paths, and dynamic command errors. Those details are useful for local diagnosis but are unnecessary for the desktop summary and unsafe for telemetry or shareable evidence. + +## Decision + +### 1. Add a separate privacy projection + +`src-tauri/src/podman_desktop.rs` converts `PodmanReclaimPlan` into `PodmanDesktopEvidence`. + +The projection includes only: + +- configured machine disk bytes; +- raw-image logical bytes; +- host allocated bytes; +- guest total, used, and available bytes; +- Podman graph-root allocated and used bytes; +- image, stopped-container, and volume logical candidate bytes; +- unused-image and stopped-container counts; +- the SHA-256 commitment to the exact unused-image candidate set; +- evidence completeness, elapsed time, stable reason codes, and stable issue codes; +- separate image, stopped-container, and volume review boundaries; +- `physically_reclaimable_bytes`, which remains unknown until a before-and-after host observation proves it. + +The projection excludes: + +- machine names and states; +- configuration, raw-image, and graph-root paths; +- image identifiers and tags; +- account-local context; +- command output and dynamic error details; +- any mutation command or approval record. + +Issue strings are reduced to the stable code before the first colon. Invalid candidate fingerprints fail closed: the fingerprint is removed, the evidence is marked incomplete, and a stable issue code is added. + +### 2. Keep the Tauri command read-only and argv-based + +`inspect_podman_reclaim` invokes the existing Rust probe using an executable plus an argument vector. It does not construct a shell string. Tauri documents commands as typed Rust functions registered once in `generate_handler!`; the desktop command follows that model and returns a serializable response. + +The desktop surface exposes no prune, remove, machine start/stop, VM deletion, TRIM, raw-image mutation, or generic command execution path. + +### 3. Keep review domains independent + +Images, stopped containers, and local volumes have separate review booleans and separate UI sections. A review signal for one domain never authorizes another domain. This preserves future compatibility with distinct approval records and least-privilege workflows. + +### 4. Keep visual semantics explicit and accessible + +The panel uses semantic headings, definition lists, buttons, `role="status"` for progress and results, and `role="alert"` for errors. WCAG 2.2 requires status messages to be programmatically determinable without moving focus; the component uses live status regions for that purpose. + +The UI never uses color as the only carrier of completeness. Text labels always state “증거 완전” or “부분 증거.” + +### 5. Preserve standalone and MSA compatibility + +The desktop response is a versioned JSON contract with no dependency on Naruon or another CWL service. DiskSage runs independently. A future Naruon or fleet-management adapter may consume the same privacy-safe schema without receiving local paths or identifiers. + +## Consequences + +### Positive + +- Buyers can inspect a concrete Podman storage gap from the main Cleanup workflow. +- Logical size, host allocation, guest use, and verified physical reclaimability cannot be silently conflated. +- Local identifiers stay outside the frontend contract, telemetry, and shareable evidence boundary. +- The architecture can later add separate governed image, container, and volume approval records without changing the read-only evidence contract. +- Headless API validation and view-state tests remain deterministic and are included in the 100% frontend statement, branch, function, and line coverage gate. + +### Negative + +- The UI intentionally cannot perform cleanup. Operators must use a separate reviewed workflow until a mutation design includes exact candidate binding, independent approval, rollback evidence, and before-and-after host verification. +- Some evidence remains unavailable when Podman is absent, the machine is stopped, or the API is unhealthy. Unknown values remain `null`; the UI never converts missing evidence to zero. + +## Verification matrix + +| Invariant | Deterministic evidence | +|---|---| +| No machine names or paths in desktop JSON | Rust serialization test searches for private fixture values | +| Image/container/volume review separation | Rust projection test and TypeScript view-model test | +| Invalid fingerprint fails closed | Rust and TypeScript malformed-fingerprint tests | +| Missing observations stay unknown | Rust and TypeScript null-preservation tests | +| Exact Tauri command contract | Mocked TypeScript invoke test | +| Schema/type/range drift rejected | TypeScript parser tests | +| Progress and errors announced | Svelte markup uses `role="status"` and `role="alert"` | +| No mutation surface | Registered command list exposes inspection only | +| Frontend logic coverage | `vitest.config.ts` includes `podmanEvidence.ts` at 100% thresholds | + +## Release acceptance + +This slice is release-eligible only after the exact integrated head passes: + +1. Rust formatting and tests, including `podman_desktop` tests; +2. frontend unit tests and 100% coverage thresholds; +3. Svelte type checking and production build; +4. security and SAST workflows; +5. current-head review with no unresolved actionable finding; +6. independent non-author approval; +7. packaging, provenance, and release-acceptance workflows. + +## References + +Podman. (n.d.). *podman-machine-inspect—Inspect one or more virtual machines*. Retrieved August 5, 2026, from https://docs.podman.io/en/stable/markdown/podman-machine-inspect.1.html + +Podman. (n.d.). *podman-system-df—Show Podman disk usage*. Retrieved August 5, 2026, from https://docs.podman.io/en/latest/markdown/podman-system-df.1.html + +Tauri Programme within The Commons Conservancy. (2026). *Calling Rust from the frontend*. https://v2.tauri.app/develop/calling-rust/ + +World Wide Web Consortium. (2024, December 12). *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ + +World Wide Web Consortium. (2025). *Understanding Success Criterion 4.1.3: Status messages*. https://www.w3.org/WAI/WCAG22/Understanding/status-messages From 2724e6cde9bf9dcc1e6a71aeba132d7d125c15bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:19:14 +0900 Subject: [PATCH 10/66] fix: fail closed on empty Podman issue codes --- src-tauri/src/podman_desktop.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/podman_desktop.rs b/src-tauri/src/podman_desktop.rs index b4aa5efe6..227848bf0 100644 --- a/src-tauri/src/podman_desktop.rs +++ b/src-tauri/src/podman_desktop.rs @@ -110,7 +110,12 @@ fn valid_sha256(value: &str) -> bool { } fn stable_issue_code(value: &str) -> String { - value.split(':').next().unwrap_or("podman-evidence-error").to_string() + value + .split(':') + .next() + .filter(|code| !code.is_empty()) + .unwrap_or("podman-evidence-error") + .to_string() } fn has_action(plan: &PodmanReclaimPlan, kind: PodmanRecommendedActionKind) -> bool { @@ -136,9 +141,7 @@ pub fn redact_podman_reclaim_plan(plan: PodmanReclaimPlan) -> PodmanDesktopEvide .unused_images .as_ref() .map(|images| images.candidate_set_sha256.clone()); - let fingerprint_valid = candidate_fingerprint - .as_deref() - .is_none_or(valid_sha256); + let fingerprint_valid = candidate_fingerprint.as_deref().is_none_or(valid_sha256); if !fingerprint_valid { issue_codes.push("podman-desktop-invalid-candidate-fingerprint".to_string()); } @@ -368,7 +371,6 @@ mod tests { assert!(!json.contains("private-machine")); assert!(!json.contains("/Users/private")); assert!(!json.contains("/var/home/private")); - assert!(!json.contains("graph_root")); } #[test] @@ -416,7 +418,10 @@ mod tests { #[test] fn invalid_fingerprint_fails_closed_without_hiding_other_evidence() { let mut plan = complete_plan(); - plan.unused_images.as_mut().unwrap().candidate_set_sha256 = "BAD".to_string(); + plan.unused_images + .as_mut() + .unwrap() + .candidate_set_sha256 = "BAD".to_string(); let evidence = redact_podman_reclaim_plan(plan); assert!(!evidence.evidence_complete); assert_eq!(evidence.candidates.image_candidate_set_sha256, None); @@ -454,8 +459,10 @@ mod tests { } #[test] - fn issue_code_fallback_is_stable_for_empty_detail() { - assert_eq!(stable_issue_code(""), ""); + fn issue_code_fallback_and_fingerprint_validation_are_stable() { + assert_eq!(stable_issue_code(""), "podman-evidence-error"); + assert_eq!(stable_issue_code(":private"), "podman-evidence-error"); + assert_eq!(stable_issue_code("stable:private"), "stable"); assert!(valid_sha256(&"0".repeat(64))); assert!(!valid_sha256(&"A".repeat(64))); assert!(!valid_sha256("short")); From 4e708c194f0f17860da7bfd336b615b4cf23a2bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 21:24:30 +0900 Subject: [PATCH 11/66] test: enforce formatting type checks and coverage --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7137a9b9..b6c5ccec8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,8 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: src-tauri + - name: Rust formatting + run: cargo fmt --manifest-path src-tauri/Cargo.toml -- --check - name: Rust tests (includes unix symlink test) run: cargo test --manifest-path src-tauri/Cargo.toml - name: Headless cloud planner tests @@ -40,6 +42,8 @@ jobs: node-version: 20.19.0 - run: npm ci - run: npm test + - run: npm run coverage + - run: npm run check - run: npm run build llm-engine-build: From a065ce73d139be803289cc9c4519aec451f314e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:05:31 +0900 Subject: [PATCH 12/66] style: apply rustfmt to Podman desktop command registration --- src-tauri/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d466f3c41..97c361c1c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -126,8 +126,8 @@ pub fn run() { commands::adopt_existing_cloud_candidate, commands::attest_cloud_copy, commands::trash_verified_cloud_source, - podman_desktop::inspect_podman_reclaim + podman_desktop::inspect_podman_reclaim, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); -} +} \ No newline at end of file From 4e3764663049576cf072543b0ff4658f54a418b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:17:23 +0900 Subject: [PATCH 13/66] ci: capture exact rustfmt evidence for PR 133 --- .github/workflows/pr133-rustfmt-evidence.yml | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/pr133-rustfmt-evidence.yml diff --git a/.github/workflows/pr133-rustfmt-evidence.yml b/.github/workflows/pr133-rustfmt-evidence.yml new file mode 100644 index 000000000..2676f129f --- /dev/null +++ b/.github/workflows/pr133-rustfmt-evidence.yml @@ -0,0 +1,46 @@ +name: PR133 Rustfmt Evidence + +on: + push: + branches: [feat/podman-desktop-evidence] + paths: + - "src-tauri/src/podman_desktop.rs" + - "src-tauri/src/lib.rs" + - ".github/workflows/pr133-rustfmt-evidence.yml" + +permissions: + contents: read + +concurrency: + group: pr133-rustfmt-evidence-${{ github.ref }} + cancel-in-progress: true + +jobs: + rustfmt-evidence: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + components: rustfmt + - name: Generate exact rustfmt output + run: cargo fmt --manifest-path src-tauri/Cargo.toml + - name: Require a bounded formatting delta + run: | + set -euo pipefail + git diff --exit-code -- src-tauri/src/lib.rs src-tauri/src/podman_desktop.rs && { + echo "Expected rustfmt to produce a formatting delta." >&2 + exit 1 + } + git diff --check -- src-tauri/src/lib.rs src-tauri/src/podman_desktop.rs + - name: Upload formatted Rust sources + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr133-rustfmt-sources + path: | + src-tauri/src/lib.rs + src-tauri/src/podman_desktop.rs + if-no-files-found: error + retention-days: 1 From 1b43e5259256e5cd232d886fe9614802ad819d9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:17:53 +0900 Subject: [PATCH 14/66] ci: expose PR rustfmt artifact for exact-head repair --- .github/workflows/pr133-rustfmt-evidence.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr133-rustfmt-evidence.yml b/.github/workflows/pr133-rustfmt-evidence.yml index 2676f129f..6607d1dd4 100644 --- a/.github/workflows/pr133-rustfmt-evidence.yml +++ b/.github/workflows/pr133-rustfmt-evidence.yml @@ -1,6 +1,12 @@ name: PR133 Rustfmt Evidence on: + pull_request: + branches: [main] + paths: + - "src-tauri/src/podman_desktop.rs" + - "src-tauri/src/lib.rs" + - ".github/workflows/pr133-rustfmt-evidence.yml" push: branches: [feat/podman-desktop-evidence] paths: @@ -12,7 +18,7 @@ permissions: contents: read concurrency: - group: pr133-rustfmt-evidence-${{ github.ref }} + group: pr133-rustfmt-evidence-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: From cd9c7ad65671c9af84429f32a1af8f62d007a51d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:20:00 +0900 Subject: [PATCH 15/66] style: apply exact rustfmt output for Podman desktop evidence --- src-tauri/src/podman_desktop.rs | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/podman_desktop.rs b/src-tauri/src/podman_desktop.rs index 227848bf0..c8a14e181 100644 --- a/src-tauri/src/podman_desktop.rs +++ b/src-tauri/src/podman_desktop.rs @@ -6,8 +6,8 @@ //! image identifiers, tags, or shell command text. use crate::podman_reclaim::{ - probe_podman_reclaim, PodmanRecommendedActionKind, PodmanReclaimPlan, - DEFAULT_PODMAN_MACHINE, DEFAULT_PROBE_TIMEOUT, + probe_podman_reclaim, PodmanReclaimPlan, PodmanRecommendedActionKind, DEFAULT_PODMAN_MACHINE, + DEFAULT_PROBE_TIMEOUT, }; use serde::Serialize; use std::path::Path; @@ -162,10 +162,7 @@ pub fn redact_podman_reclaim_plan(plan: PodmanReclaimPlan) -> PodmanDesktopEvide .guest_filesystem .as_ref() .map(|guest| guest.total_bytes), - guest_used_bytes: plan - .guest_filesystem - .as_ref() - .map(|guest| guest.used_bytes), + guest_used_bytes: plan.guest_filesystem.as_ref().map(|guest| guest.used_bytes), guest_available_bytes: plan .guest_filesystem .as_ref() @@ -174,10 +171,7 @@ pub fn redact_podman_reclaim_plan(plan: PodmanReclaimPlan) -> PodmanDesktopEvide .store .as_ref() .map(|store| store.graph_root_allocated_bytes), - graph_root_used_bytes: plan - .store - .as_ref() - .map(|store| store.graph_root_used_bytes), + graph_root_used_bytes: plan.store.as_ref().map(|store| store.graph_root_used_bytes), }; let candidates = PodmanDesktopCandidateEvidence { @@ -197,10 +191,7 @@ pub fn redact_podman_reclaim_plan(plan: PodmanReclaimPlan) -> PodmanDesktopEvide .unused_images .as_ref() .map(|images| images.unused_records), - stopped_container_records: plan - .store - .as_ref() - .map(|store| store.containers_stopped), + stopped_container_records: plan.store.as_ref().map(|store| store.containers_stopped), image_candidate_set_sha256: candidate_fingerprint.filter(|_| fingerprint_valid), }; @@ -261,8 +252,8 @@ pub fn inspect_podman_reclaim() -> PodmanDesktopEvidence { mod tests { use super::*; use crate::podman_reclaim::{ - GuestFilesystemEvidence, PodmanMachineEvidence, PodmanRecommendedAction, - PodmanReclaimAssessment, PodmanStoreEvidence, PodmanSystemDfCategoryEvidence, + GuestFilesystemEvidence, PodmanMachineEvidence, PodmanReclaimAssessment, + PodmanRecommendedAction, PodmanStoreEvidence, PodmanSystemDfCategoryEvidence, PodmanSystemDfEvidence, PodmanUnusedImageEvidence, RawImageEvidence, PODMAN_RECLAIM_SCHEMA_KIND, }; @@ -418,10 +409,7 @@ mod tests { #[test] fn invalid_fingerprint_fails_closed_without_hiding_other_evidence() { let mut plan = complete_plan(); - plan.unused_images - .as_mut() - .unwrap() - .candidate_set_sha256 = "BAD".to_string(); + plan.unused_images.as_mut().unwrap().candidate_set_sha256 = "BAD".to_string(); let evidence = redact_podman_reclaim_plan(plan); assert!(!evidence.evidence_complete); assert_eq!(evidence.candidates.image_candidate_set_sha256, None); From caab8fdf04b08a4408691359348a4dfa937ecd76 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:20:15 +0900 Subject: [PATCH 16/66] ci: remove completed PR 133 rustfmt evidence workflow --- .github/workflows/pr133-rustfmt-evidence.yml | 52 -------------------- 1 file changed, 52 deletions(-) delete mode 100644 .github/workflows/pr133-rustfmt-evidence.yml diff --git a/.github/workflows/pr133-rustfmt-evidence.yml b/.github/workflows/pr133-rustfmt-evidence.yml deleted file mode 100644 index 6607d1dd4..000000000 --- a/.github/workflows/pr133-rustfmt-evidence.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: PR133 Rustfmt Evidence - -on: - pull_request: - branches: [main] - paths: - - "src-tauri/src/podman_desktop.rs" - - "src-tauri/src/lib.rs" - - ".github/workflows/pr133-rustfmt-evidence.yml" - push: - branches: [feat/podman-desktop-evidence] - paths: - - "src-tauri/src/podman_desktop.rs" - - "src-tauri/src/lib.rs" - - ".github/workflows/pr133-rustfmt-evidence.yml" - -permissions: - contents: read - -concurrency: - group: pr133-rustfmt-evidence-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - rustfmt-evidence: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - with: - components: rustfmt - - name: Generate exact rustfmt output - run: cargo fmt --manifest-path src-tauri/Cargo.toml - - name: Require a bounded formatting delta - run: | - set -euo pipefail - git diff --exit-code -- src-tauri/src/lib.rs src-tauri/src/podman_desktop.rs && { - echo "Expected rustfmt to produce a formatting delta." >&2 - exit 1 - } - git diff --check -- src-tauri/src/lib.rs src-tauri/src/podman_desktop.rs - - name: Upload formatted Rust sources - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: pr133-rustfmt-sources - path: | - src-tauri/src/lib.rs - src-tauri/src/podman_desktop.rs - if-no-files-found: error - retention-days: 1 From f9da84cf57eb11cd8693195cb95c0f3ce39c1762 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:22:13 +0900 Subject: [PATCH 17/66] style: apply exact rustfmt module ordering --- src-tauri/src/lib.rs | 80 ++++++++++++++++++++++---------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 97c361c1c..61df916fc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,77 +1,77 @@ // coverage 빌드(비-테스트)에서는 run()이 빠져 모듈 내용이 테스트에서만 쓰이므로 dead_code만 허용 -#[cfg_attr(coverage, allow(dead_code))] -mod dupes; -#[cfg_attr(coverage, allow(dead_code))] -mod commands; -#[cfg_attr(coverage, allow(dead_code))] -mod scanner; -#[cfg_attr(coverage, allow(dead_code))] -mod userrules; -#[cfg_attr(coverage, allow(dead_code))] -mod settings; -#[cfg_attr(coverage, allow(dead_code))] -mod safety; -#[cfg(all(test, target_os = "macos"))] -mod macos_temp_guard_tests; -#[cfg_attr(coverage, allow(dead_code))] -mod rules; -#[cfg_attr(coverage, allow(dead_code))] -mod dev_artifacts; -#[cfg_attr(coverage, allow(dead_code))] -mod ontology; -#[cfg_attr(coverage, allow(dead_code))] -mod inventory; -#[cfg_attr(coverage, allow(dead_code))] -mod organize; -#[cfg_attr(coverage, allow(dead_code))] -mod llm; -#[cfg_attr(coverage, allow(dead_code))] -mod web; -#[cfg_attr(coverage, allow(dead_code))] -mod reasoning; -#[cfg_attr(coverage, allow(dead_code))] -mod dataset_metadata; pub mod archive_git_tree; #[cfg_attr(coverage, allow(dead_code))] pub mod cloud; -/// Typed backend-authored presentation contract for cloud archive plans. -pub mod cloud_plan_view; -pub mod cloud_local_inventory; -pub mod cloud_local_eviction; -pub mod cloud_local_eviction_batch; #[cfg(not(coverage))] pub mod cloud_eviction; +pub mod cloud_local_eviction; +pub mod cloud_local_eviction_batch; +pub mod cloud_local_inventory; +/// Typed backend-authored presentation contract for cloud archive plans. +pub mod cloud_plan_view; pub mod cloud_review; pub mod cloud_transfer; +#[cfg_attr(coverage, allow(dead_code))] +mod commands; pub mod content_digest; +#[cfg_attr(coverage, allow(dead_code))] +mod dataset_metadata; +#[cfg_attr(coverage, allow(dead_code))] +mod dev_artifacts; +#[cfg_attr(coverage, allow(dead_code))] +mod dupes; pub mod duplicate_audit; +pub mod git_worktree; pub mod icloud_sync_health; pub mod incomplete_download; pub mod incomplete_download_materialization; pub mod incomplete_download_materialization_destination; pub mod incomplete_download_materialization_execution; pub mod incomplete_download_recovery; -pub mod git_worktree; +#[cfg_attr(coverage, allow(dead_code))] +mod inventory; +#[cfg_attr(coverage, allow(dead_code))] +mod llm; +#[cfg(all(test, target_os = "macos"))] +mod macos_temp_guard_tests; pub mod maven_cache; pub mod multipart_archive; pub mod naruon_capacity; pub mod naruon_cloud_copy_readiness; pub mod naruon_lineage; +#[cfg_attr(coverage, allow(dead_code))] +mod ontology; +#[cfg_attr(coverage, allow(dead_code))] +mod organize; /// Privacy-safe desktop projection of read-only Podman reclaim evidence. pub mod podman_desktop; /// Read-only, fail-closed Podman VM/store reclaim evidence. pub mod podman_reclaim; +pub mod private_evidence; pub mod provider_api_client; pub mod provider_capacity; pub mod provider_client_runtime; pub mod provider_evidence; pub mod provider_oauth; pub mod provider_sync; -pub mod private_evidence; +#[cfg_attr(coverage, allow(dead_code))] +mod reasoning; /// Read-only, fail-closed logical/allocation/reclaimability evidence. pub mod reclaim; +#[cfg_attr(coverage, allow(dead_code))] +mod rules; +#[cfg_attr(coverage, allow(dead_code))] +mod safety; +#[cfg_attr(coverage, allow(dead_code))] +mod scanner; pub mod semantic_catalog; +#[cfg_attr(coverage, allow(dead_code))] +mod settings; +#[cfg_attr(coverage, allow(dead_code))] +mod userrules; pub mod volume_pressure; +#[cfg_attr(coverage, allow(dead_code))] +mod web; // coverage 빌드에서 제외 — GUI 런타임은 헤드리스 테스트로 실행 불가 #[cfg(not(coverage))] @@ -130,4 +130,4 @@ pub fn run() { ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); -} \ No newline at end of file +} From 4063824dd526a7116de1b8378e2b743bd77cf896 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:23:33 +0900 Subject: [PATCH 18/66] ci: capture complete rustfmt diff for PR 133 --- .github/workflows/pr133-rustfmt-diff.yml | 40 ++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/pr133-rustfmt-diff.yml diff --git a/.github/workflows/pr133-rustfmt-diff.yml b/.github/workflows/pr133-rustfmt-diff.yml new file mode 100644 index 000000000..42a5202c8 --- /dev/null +++ b/.github/workflows/pr133-rustfmt-diff.yml @@ -0,0 +1,40 @@ +name: PR133 Rustfmt Diff + +on: + pull_request: + branches: [main] + paths: + - "src-tauri/**" + - ".github/workflows/pr133-rustfmt-diff.yml" + +permissions: + contents: read + +concurrency: + group: pr133-rustfmt-diff-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + rustfmt-diff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + components: rustfmt + - name: Generate exact rustfmt diff + run: | + set -euo pipefail + cargo fmt --manifest-path src-tauri/Cargo.toml + git diff --binary > rustfmt.patch + test -s rustfmt.patch + git diff --check + - name: Upload exact rustfmt diff + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pr133-rustfmt-diff + path: rustfmt.patch + if-no-files-found: error + retention-days: 1 From e73763f679c350f6df58022fc07791b9b7183a2b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:25:08 +0900 Subject: [PATCH 19/66] chore: remove one-shot PR133 rustfmt workflow --- .github/workflows/pr133-rustfmt-diff.yml | 40 ------------------------ 1 file changed, 40 deletions(-) delete mode 100644 .github/workflows/pr133-rustfmt-diff.yml diff --git a/.github/workflows/pr133-rustfmt-diff.yml b/.github/workflows/pr133-rustfmt-diff.yml deleted file mode 100644 index 42a5202c8..000000000 --- a/.github/workflows/pr133-rustfmt-diff.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: PR133 Rustfmt Diff - -on: - pull_request: - branches: [main] - paths: - - "src-tauri/**" - - ".github/workflows/pr133-rustfmt-diff.yml" - -permissions: - contents: read - -concurrency: - group: pr133-rustfmt-diff-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - rustfmt-diff: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - with: - components: rustfmt - - name: Generate exact rustfmt diff - run: | - set -euo pipefail - cargo fmt --manifest-path src-tauri/Cargo.toml - git diff --binary > rustfmt.patch - test -s rustfmt.patch - git diff --check - - name: Upload exact rustfmt diff - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: pr133-rustfmt-diff - path: rustfmt.patch - if-no-files-found: error - retention-days: 1 From 05dc8254d3a906b71785e9d5edfddd79b6e04f5e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:25:11 +0900 Subject: [PATCH 20/66] ci: apply bounded repository rustfmt repair --- .github/workflows/pr133-rustfmt-diff.yml | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/pr133-rustfmt-diff.yml diff --git a/.github/workflows/pr133-rustfmt-diff.yml b/.github/workflows/pr133-rustfmt-diff.yml new file mode 100644 index 000000000..04bebd830 --- /dev/null +++ b/.github/workflows/pr133-rustfmt-diff.yml @@ -0,0 +1,50 @@ +name: PR133 Rustfmt Repair + +on: + push: + branches: [feat/podman-desktop-evidence] + paths: + - ".github/workflows/pr133-rustfmt-diff.yml" + +permissions: + contents: write + +concurrency: + group: pr133-rustfmt-repair-${{ github.ref }} + cancel-in-progress: false + +jobs: + rustfmt-repair: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: true + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + components: rustfmt + - name: Apply exact rustfmt output + run: cargo fmt --manifest-path src-tauri/Cargo.toml + - name: Verify bounded formatting-only changes + run: | + set -euo pipefail + git diff --check + mapfile -t changed_files < <(git diff --name-only) + test "${#changed_files[@]}" -gt 0 + for changed_file in "${changed_files[@]}"; do + case "$changed_file" in + src-tauri/*.rs|src-tauri/src/*.rs|src-tauri/src/**/*.rs) ;; + *) + echo "Unexpected rustfmt output outside Rust sources: $changed_file" >&2 + exit 1 + ;; + esac + done + - name: Commit exact rustfmt output + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -- src-tauri + git commit -m "style: apply repository-wide rustfmt output" + git push origin "HEAD:${GITHUB_REF_NAME}" From d0f90c296278e8e3a0577cd0f6d3d913c349b8ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:25:22 +0000 Subject: [PATCH 21/66] style: apply repository-wide rustfmt output --- src-tauri/src/bin/disksage-reclaim-plan.rs | 6 +- src-tauri/src/dataset_metadata.rs | 5 +- src-tauri/src/dev_artifacts.rs | 29 +- src-tauri/src/dupes.rs | 145 +++++++-- src-tauri/src/inventory.rs | 46 ++- src-tauri/src/llm/backend.rs | 25 +- src-tauri/src/llm/engine.rs | 29 +- src-tauri/src/llm/mod.rs | 44 ++- src-tauri/src/llm/model.rs | 5 +- src-tauri/src/llm/parse.rs | 88 ++++-- src-tauri/src/llm/prompt.rs | 33 +- src-tauri/src/llm/verdict.rs | 18 +- src-tauri/src/ontology.rs | 283 ++++++++++++++---- src-tauri/src/organize.rs | 118 ++++++-- src-tauri/src/reasoning.rs | 67 ++++- src-tauri/src/rules.rs | 55 +++- src-tauri/src/safety.rs | 148 +++++++-- src-tauri/src/scanner.rs | 30 +- src-tauri/src/settings.rs | 4 +- src-tauri/src/userrules.rs | 249 ++++++++++++--- src-tauri/src/web/mod.rs | 16 +- .../tests/cloud_transfer_coverage_contract.rs | 10 +- ...local_eviction_batch_documentation_test.rs | 3 +- 23 files changed, 1143 insertions(+), 313 deletions(-) diff --git a/src-tauri/src/bin/disksage-reclaim-plan.rs b/src-tauri/src/bin/disksage-reclaim-plan.rs index efd93e99b..a8a82b7e4 100644 --- a/src-tauri/src/bin/disksage-reclaim-plan.rs +++ b/src-tauri/src/bin/disksage-reclaim-plan.rs @@ -132,11 +132,7 @@ mod tests { #[test] fn double_dash_preserves_option_like_paths() { let parsed = expect_run( - parse_args([ - OsString::from("--"), - OsString::from("--not-an-option"), - ]) - .unwrap(), + parse_args([OsString::from("--"), OsString::from("--not-an-option")]).unwrap(), ); assert_eq!(parsed.paths, [PathBuf::from("--not-an-option")]); diff --git a/src-tauri/src/dataset_metadata.rs b/src-tauri/src/dataset_metadata.rs index bc98047b6..277d015ed 100644 --- a/src-tauri/src/dataset_metadata.rs +++ b/src-tauri/src/dataset_metadata.rs @@ -657,10 +657,7 @@ mod tests { fn spreadsheet_profile_keeps_schema_but_not_cell_values() { let rows = vec![ vec![Data::String("email".into()), Data::String("amount".into())], - vec![ - Data::String("person@example.com".into()), - Data::Int(42), - ], + vec![Data::String("person@example.com".into()), Data::Int(42)], vec![Data::Empty, Data::Float(3.5)], ]; let mut profile = DatasetProfile { diff --git a/src-tauri/src/dev_artifacts.rs b/src-tauri/src/dev_artifacts.rs index 9d70123a2..2fe67f356 100644 --- a/src-tauri/src/dev_artifacts.rs +++ b/src-tauri/src/dev_artifacts.rs @@ -28,7 +28,9 @@ fn artifact_kind(name: &str) -> Option<&'static (&'static str, &'static [&'stati fn age_days(path: &Path, now_ms: u64) -> u64 { let Ok(md) = path.metadata() else { return 0 }; let Ok(mtime) = md.modified() else { return 0 }; - let Ok(dur) = mtime.duration_since(std::time::UNIX_EPOCH) else { return 0 }; + let Ok(dur) = mtime.duration_since(std::time::UNIX_EPOCH) else { + return 0; + }; let mtime_ms = dur.as_millis() as u64; now_ms.saturating_sub(mtime_ms) / 86_400_000 } @@ -57,8 +59,12 @@ pub fn find_artifacts(root: &Path, min_age_days: u64, now_ms: u64) -> Vec Vec = top_level .into_iter() .filter_map(|path| { - let age = if now_ms == u64::MAX { u64::MAX } else { age_days(path, now_ms) }; + let age = if now_ms == u64::MAX { + u64::MAX + } else { + age_days(path, now_ms) + }; if age < min_age_days { return None; } @@ -91,7 +101,9 @@ pub fn find_artifacts(root: &Path, min_age_days: u64, now_ms: u64) -> Vec std::path::PathBuf { + fn project( + root: &std::path::Path, + name: &str, + marker: &str, + artifact: &str, + ) -> std::path::PathBuf { let p = root.join(name); fs::create_dir_all(&p).unwrap(); fs::write(p.join(marker), b"{}").unwrap(); diff --git a/src-tauri/src/dupes.rs b/src-tauri/src/dupes.rs index 8e200eb11..97e92bb7b 100644 --- a/src-tauri/src/dupes.rs +++ b/src-tauri/src/dupes.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use std::path::{Path, PathBuf}; use std::io::Read; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct FileEntry { @@ -11,7 +11,8 @@ pub struct FileEntry { /// Metadata의 수정시각 → epoch millis. 지원 안 되면 0 (플랫폼별 실패는 드묾; 0 폴백). fn mtime_millis(md: &std::fs::Metadata) -> u64 { - md.modified().ok() + md.modified() + .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_millis() as u64) .unwrap_or(0) @@ -27,8 +28,11 @@ pub fn group_by_size(files: Vec) -> Vec> { } by_size.entry(f.size).or_default().push(f); } - let mut groups: Vec> = - by_size.into_iter().filter(|(_, v)| v.len() >= 2).map(|(_, v)| v).collect(); + let mut groups: Vec> = by_size + .into_iter() + .filter(|(_, v)| v.len() >= 2) + .map(|(_, v)| v) + .collect(); groups.sort_by(|a, b| b[0].size.cmp(&a[0].size)); groups } @@ -110,8 +114,12 @@ pub fn find_duplicates(files: Vec, prefix_len: usize) -> Vec=2로 생성되지만, 다른 곳에서 만들어져도 패닉 없이 - let wa = a.size.saturating_mul((a.paths.len() as u64).saturating_sub(1)); - let wb = b.size.saturating_mul((b.paths.len() as u64).saturating_sub(1)); + let wa = a + .size + .saturating_mul((a.paths.len() as u64).saturating_sub(1)); + let wb = b + .size + .saturating_mul((b.paths.len() as u64).saturating_sub(1)); wb.cmp(&wa) }); out @@ -129,18 +137,28 @@ pub fn collect_files(root: &Path) -> Vec { .into_iter() .filter_map(Result::ok) .filter(|e| e.file_type().is_file()) - .filter_map(|e| e.metadata().ok().map(|md| FileEntry { path: e.path(), size: md.len(), mtime_ms: mtime_millis(&md) })) + .filter_map(|e| { + e.metadata().ok().map(|md| FileEntry { + path: e.path(), + size: md.len(), + mtime_ms: mtime_millis(&md), + }) + }) .collect() } #[cfg(test)] mod tests { use super::*; - use std::path::PathBuf; use std::io::Write; + use std::path::PathBuf; fn fe(p: &str, size: u64) -> FileEntry { - FileEntry { path: PathBuf::from(p), size, mtime_ms: 0 } + FileEntry { + path: PathBuf::from(p), + size, + mtime_ms: 0, + } } fn write_file(dir: &std::path::Path, name: &str, bytes: &[u8]) -> PathBuf { @@ -155,9 +173,9 @@ mod tests { let files = vec![ fe("/a", 100), fe("/b", 100), - fe("/c", 50), // 단독 — 제외 - fe("/d", 0), // 0바이트 — 제외 - fe("/e", 0), // 0바이트 — 제외 + fe("/c", 50), // 단독 — 제외 + fe("/d", 0), // 0바이트 — 제외 + fe("/e", 0), // 0바이트 — 제외 fe("/f", 100), ]; let groups = group_by_size(files); @@ -169,10 +187,7 @@ mod tests { #[test] fn multiple_size_groups_sorted_desc() { - let files = vec![ - fe("/a", 10), fe("/b", 10), - fe("/c", 999), fe("/d", 999), - ]; + let files = vec![fe("/a", 10), fe("/b", 10), fe("/c", 999), fe("/d", 999)]; let groups = group_by_size(files); assert_eq!(groups.len(), 2); // 그룹은 크기 내림차순: 999 그룹이 먼저 @@ -230,11 +245,31 @@ mod tests { let solo = write_file(tmp.path(), "solo", b"different length entirely"); let files = vec![ - FileEntry { path: d1, size: 16, mtime_ms: 0 }, - FileEntry { path: d2, size: 16, mtime_ms: 0 }, - FileEntry { path: n1, size: 16, mtime_ms: 0 }, - FileEntry { path: n2, size: 16, mtime_ms: 0 }, - FileEntry { path: solo.clone(), size: std::fs::metadata(&solo).unwrap().len(), mtime_ms: 0 }, + FileEntry { + path: d1, + size: 16, + mtime_ms: 0, + }, + FileEntry { + path: d2, + size: 16, + mtime_ms: 0, + }, + FileEntry { + path: n1, + size: 16, + mtime_ms: 0, + }, + FileEntry { + path: n2, + size: 16, + mtime_ms: 0, + }, + FileEntry { + path: solo.clone(), + size: std::fs::metadata(&solo).unwrap().len(), + mtime_ms: 0, + }, ]; let groups = find_duplicates(files, 8); @@ -245,7 +280,13 @@ mod tests { let names: Vec = groups[0] .paths .iter() - .map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().into_owned()) + .map(|p| { + std::path::Path::new(p) + .file_name() + .unwrap() + .to_string_lossy() + .into_owned() + }) .collect(); assert!(names.contains(&"d1".to_string()) && names.contains(&"d2".to_string())); } @@ -260,10 +301,26 @@ mod tests { let s1 = write_file(tmp.path(), "s1", b"tenbytes!!"); let s2 = write_file(tmp.path(), "s2", b"tenbytes!!"); let files = vec![ - FileEntry { path: b1, size: 1000, mtime_ms: 0 }, - FileEntry { path: b2, size: 1000, mtime_ms: 0 }, - FileEntry { path: s1, size: 10, mtime_ms: 0 }, - FileEntry { path: s2, size: 10, mtime_ms: 0 }, + FileEntry { + path: b1, + size: 1000, + mtime_ms: 0, + }, + FileEntry { + path: b2, + size: 1000, + mtime_ms: 0, + }, + FileEntry { + path: s1, + size: 10, + mtime_ms: 0, + }, + FileEntry { + path: s2, + size: 10, + mtime_ms: 0, + }, ]; let groups = find_duplicates(files, 4096); assert_eq!(groups.len(), 2); @@ -277,8 +334,16 @@ mod tests { let a = write_file(tmp.path(), "a", b"AAAA1111"); let b = write_file(tmp.path(), "b", b"BBBB2222"); let files = vec![ - FileEntry { path: a, size: 8, mtime_ms: 0 }, - FileEntry { path: b, size: 8, mtime_ms: 0 }, + FileEntry { + path: a, + size: 8, + mtime_ms: 0, + }, + FileEntry { + path: b, + size: 8, + mtime_ms: 0, + }, ]; assert!(find_duplicates(files, 4).is_empty()); } @@ -289,9 +354,21 @@ mod tests { let d1 = write_file(tmp.path(), "d1", b"same content x"); let d2 = write_file(tmp.path(), "d2", b"same content x"); let files = vec![ - FileEntry { path: d1, size: 14, mtime_ms: 0 }, - FileEntry { path: d2, size: 14, mtime_ms: 0 }, - FileEntry { path: tmp.path().join("ghost"), size: 14, mtime_ms: 0 }, // 존재하지 않음 + FileEntry { + path: d1, + size: 14, + mtime_ms: 0, + }, + FileEntry { + path: d2, + size: 14, + mtime_ms: 0, + }, + FileEntry { + path: tmp.path().join("ghost"), + size: 14, + mtime_ms: 0, + }, // 존재하지 않음 ]; // ghost는 크기 그룹엔 들어가지만 해시 단계서 실패 → 조용히 빠지고 d1/d2는 확정 let groups = find_duplicates(files, 4096); @@ -323,7 +400,10 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); write_file(tmp.path(), "x.bin", b"data"); let files = collect_files(tmp.path()); - assert!(files.iter().any(|f| f.mtime_ms > 0), "mtime_ms filled for a real file"); + assert!( + files.iter().any(|f| f.mtime_ms > 0), + "mtime_ms filled for a real file" + ); } #[cfg(unix)] @@ -345,5 +425,4 @@ mod tests { assert!(names.contains(&"nested.bin".to_string())); assert!(!names.contains(&"link.bin".to_string()), "심링크 제외"); } - } diff --git a/src-tauri/src/inventory.rs b/src-tauri/src/inventory.rs index 9537b1f5b..318b3ffb5 100644 --- a/src-tauri/src/inventory.rs +++ b/src-tauri/src/inventory.rs @@ -75,12 +75,26 @@ pub fn build_inventory(files: &[FileEntry], onto: &Ontology) -> InventoryReport let mut tallies: Vec = acc .into_iter() - .map(|(class_id, (label, bytes, count))| ClassTally { class_id, label, bytes, count }) + .map(|(class_id, (label, bytes, count))| ClassTally { + class_id, + label, + bytes, + count, + }) .collect(); // bytes 내림차순, 동점은 class_id로 결정적 정렬(HashMap 순서 무작위성 → UI 깜빡임 방지) - tallies.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.class_id.cmp(&b.class_id))); - - InventoryReport { tallies, unknown_bytes, unknown_count, unknown_samples } + tallies.sort_by(|a, b| { + b.bytes + .cmp(&a.bytes) + .then_with(|| a.class_id.cmp(&b.class_id)) + }); + + InventoryReport { + tallies, + unknown_bytes, + unknown_count, + unknown_samples, + } } #[cfg(test)] @@ -99,7 +113,11 @@ dm:Code a owl:Class ; rdfs:label "코드"@ko . "#; fn fe(p: &str, size: u64) -> FileEntry { - FileEntry { path: PathBuf::from(p), size, mtime_ms: 0 } + FileEntry { + path: PathBuf::from(p), + size, + mtime_ms: 0, + } } #[test] @@ -107,7 +125,7 @@ dm:Code a owl:Class ; rdfs:label "코드"@ko . // Image assert_eq!(classify(&PathBuf::from("/x/a.png")), Some("Image")); assert_eq!(classify(&PathBuf::from("/x/b.JPG")), Some("Image")); // 대소문자 무관 - // Code + // Code assert_eq!(classify(&PathBuf::from("/x/c.rs")), Some("Code")); // Video assert_eq!(classify(&PathBuf::from("/x/movie.mp4")), Some("Video")); @@ -128,9 +146,9 @@ dm:Code a owl:Class ; rdfs:label "코드"@ko . let onto = parse_ttl(ONTO).unwrap(); let files = vec![ fe("/a.png", 100), - fe("/b.png", 200), // Image 합계 300, count 2 - fe("/c.rs", 50), // Code 50, count 1 - fe("/d.xyz", 999), // 미분류 → unknown + fe("/b.png", 200), // Image 합계 300, count 2 + fe("/c.rs", 50), // Code 50, count 1 + fe("/d.xyz", 999), // 미분류 → unknown ]; let rep = build_inventory(&files, &onto); // Unknown은 일급 필드 @@ -138,10 +156,16 @@ dm:Code a owl:Class ; rdfs:label "코드"@ko . assert_eq!(rep.unknown_count, 1); assert_eq!(rep.unknown_samples, vec!["/d.xyz".to_string()]); // tallies는 바이트 내림차순: Image(300) > Code(50) - assert_eq!(rep.tallies[0].class_id, "https://disksage.app/ontology#Image"); + assert_eq!( + rep.tallies[0].class_id, + "https://disksage.app/ontology#Image" + ); assert_eq!(rep.tallies[0].bytes, 300); assert_eq!(rep.tallies[0].count, 2); - assert_eq!(rep.tallies[1].class_id, "https://disksage.app/ontology#Code"); + assert_eq!( + rep.tallies[1].class_id, + "https://disksage.app/ontology#Code" + ); } #[test] diff --git a/src-tauri/src/llm/backend.rs b/src-tauri/src/llm/backend.rs index 12859f348..b4a65afcc 100644 --- a/src-tauri/src/llm/backend.rs +++ b/src-tauri/src/llm/backend.rs @@ -29,8 +29,14 @@ mod tests { use super::*; #[test] fn prefers_cuda_then_vulkan_then_cpu() { - assert_eq!(choose_backend(&[Backend::Cpu, Backend::Vulkan, Backend::Cuda], None), Backend::Cuda); - assert_eq!(choose_backend(&[Backend::Cpu, Backend::Vulkan], None), Backend::Vulkan); + assert_eq!( + choose_backend(&[Backend::Cpu, Backend::Vulkan, Backend::Cuda], None), + Backend::Cuda + ); + assert_eq!( + choose_backend(&[Backend::Cpu, Backend::Vulkan], None), + Backend::Vulkan + ); assert_eq!(choose_backend(&[Backend::Cpu], None), Backend::Cpu); } #[test] @@ -39,15 +45,24 @@ mod tests { } #[test] fn honors_override_when_available() { - assert_eq!(choose_backend(&[Backend::Cpu, Backend::Cuda], Some(Backend::Cpu)), Backend::Cpu); + assert_eq!( + choose_backend(&[Backend::Cpu, Backend::Cuda], Some(Backend::Cpu)), + Backend::Cpu + ); } #[test] fn ignores_override_when_unavailable() { - assert_eq!(choose_backend(&[Backend::Cpu], Some(Backend::Cuda)), Backend::Cpu); + assert_eq!( + choose_backend(&[Backend::Cpu], Some(Backend::Cuda)), + Backend::Cpu + ); } #[test] fn prefers_metal_over_vulkan() { // Metal이 Vulkan보다 우선 — choose_backend의 Metal 분기 커버 - assert_eq!(choose_backend(&[Backend::Vulkan, Backend::Metal, Backend::Cpu], None), Backend::Metal); + assert_eq!( + choose_backend(&[Backend::Vulkan, Backend::Metal, Backend::Cpu], None), + Backend::Metal + ); } } diff --git a/src-tauri/src/llm/engine.rs b/src-tauri/src/llm/engine.rs index b0ad3e2f2..88a1ebf5f 100644 --- a/src-tauri/src/llm/engine.rs +++ b/src-tauri/src/llm/engine.rs @@ -31,15 +31,15 @@ impl LlamaEngine { .and_then(|v| v.parse().ok()) .unwrap_or(999); let params = LlamaModelParams::default().with_n_gpu_layers(gpu_layers); - let model = LlamaModel::load_from_file(&backend, model_path, ¶ms).map_err(|e| e.to_string())?; + let model = + LlamaModel::load_from_file(&backend, model_path, ¶ms).map_err(|e| e.to_string())?; Ok(Self { backend, model }) } } impl InferenceEngine for LlamaEngine { fn infer(&self, prompt: &str) -> Result { - let ctx_params = LlamaContextParams::default() - .with_n_ctx(NonZeroU32::new(N_CTX)); + let ctx_params = LlamaContextParams::default().with_n_ctx(NonZeroU32::new(N_CTX)); let mut ctx = self .model .new_context(&self.backend, ctx_params) @@ -53,7 +53,9 @@ impl InferenceEngine for LlamaEngine { let mut batch = LlamaBatch::new(512, 1); let last = tokens.len().saturating_sub(1); for (i, tok) in tokens.iter().enumerate() { - batch.add(*tok, i as i32, &[0], i == last).map_err(|e| e.to_string())?; + batch + .add(*tok, i as i32, &[0], i == last) + .map_err(|e| e.to_string())?; } ctx.decode(&mut batch).map_err(|e| e.to_string())?; @@ -72,9 +74,16 @@ impl InferenceEngine for LlamaEngine { // requires threading an encoding_rs::Decoder through the caller. Not worth a new // dependency just to silence a warning for a single-token-at-a-time greedy loop. #[allow(deprecated)] - out.push_str(&self.model.token_to_str(token, Special::Tokenize).map_err(|e| e.to_string())?); + out.push_str( + &self + .model + .token_to_str(token, Special::Tokenize) + .map_err(|e| e.to_string())?, + ); batch.clear(); - batch.add(token, n_cur, &[0], true).map_err(|e| e.to_string())?; + batch + .add(token, n_cur, &[0], true) + .map_err(|e| e.to_string())?; n_cur += 1; generated += 1; ctx.decode(&mut batch).map_err(|e| e.to_string())?; @@ -93,7 +102,13 @@ mod tests { fn real_engine_returns_a_rated_verdict() { let path = std::env::var("DISKSAGE_MODEL").expect("set DISKSAGE_MODEL to a .gguf path"); let engine = LlamaEngine::new(std::path::Path::new(&path)).unwrap(); - let meta = FileMeta { path: "/tmp/x.log".into(), name: "x.log".into(), size: 10, mtime_days: 1, parent: "tmp".into() }; + let meta = FileMeta { + path: "/tmp/x.log".into(), + name: "x.log".into(), + size: 10, + mtime_days: 1, + parent: "tmp".into(), + }; let fv = verdict_for(&engine, &meta); assert_ne!(fv.verdict, Verdict::Unrated); // 실제 모델이면 safe/caution/keep 중 하나 } diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index e532148bc..dbb2d4bdf 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -20,9 +20,13 @@ pub use model::{verify_sha256, ModelSpec, DEFAULT}; #[cfg(not(coverage))] pub use model::download_to; #[cfg_attr(coverage, allow(unused_imports))] -pub use parse::{parse_class_pick, parse_ext_reasoning, parse_summary, parse_verdict, parse_verdict_full}; +pub use parse::{ + parse_class_pick, parse_ext_reasoning, parse_summary, parse_verdict, parse_verdict_full, +}; #[cfg_attr(coverage, allow(unused_imports))] -pub use prompt::{classify_prompt, ext_reason_prompt, summary_prompt, verdict_prompt, ExtReasoning, FileMeta}; +pub use prompt::{ + classify_prompt, ext_reason_prompt, summary_prompt, verdict_prompt, ExtReasoning, FileMeta, +}; #[cfg_attr(coverage, allow(unused_imports))] pub use verdict::{FileVerdict, Verdict}; @@ -39,11 +43,19 @@ pub fn verdict_for(engine: &dyn InferenceEngine, meta: &FileMeta) -> FileVerdict Ok(out) => parse_verdict_full(&out), Err(_) => (Verdict::Unrated, String::new()), }; - FileVerdict { path: meta.path.clone(), verdict, reason } + FileVerdict { + path: meta.path.clone(), + verdict, + reason, + } } /// 후보 목록 중 클래스 선택. infer 실패·범위 밖은 None(자유 생성 거부). -pub fn pick_class(engine: &dyn InferenceEngine, meta: &FileMeta, candidates: &[&str]) -> Option { +pub fn pick_class( + engine: &dyn InferenceEngine, + meta: &FileMeta, + candidates: &[&str], +) -> Option { let out = engine.infer(&classify_prompt(meta, candidates)).ok()?; parse_class_pick(&out, candidates) } @@ -55,7 +67,11 @@ pub fn summarize_unknown(engine: &dyn InferenceEngine, samples: &[FileMeta]) -> } /// 확장자 하나를 추론(type + 제안 class). infer 실패·파싱 실패는 None. -pub fn reason_extension(engine: &dyn InferenceEngine, ext: &str, candidates: &[&str]) -> Option { +pub fn reason_extension( + engine: &dyn InferenceEngine, + ext: &str, + candidates: &[&str], +) -> Option { let out = engine.infer(&ext_reason_prompt(ext, candidates)).ok()?; parse_ext_reasoning(&out, candidates) } @@ -66,11 +82,18 @@ mod tests { struct Fake(Result); impl InferenceEngine for Fake { - fn infer(&self, _p: &str) -> Result { self.0.clone() } + fn infer(&self, _p: &str) -> Result { + self.0.clone() + } } fn meta() -> FileMeta { - FileMeta { path: "/downloads/old_report.pdf".into(), name: "old_report.pdf".into(), - size: 2_400_000, mtime_days: 420, parent: "downloads".into() } + FileMeta { + path: "/downloads/old_report.pdf".into(), + name: "old_report.pdf".into(), + size: 2_400_000, + mtime_days: 420, + parent: "downloads".into(), + } } #[test] @@ -91,7 +114,10 @@ mod tests { #[test] fn pick_class_returns_candidate() { let e = Fake(Ok(r#"{"class":"Image"}"#.into())); - assert_eq!(pick_class(&e, &meta(), &["Image", "Doc"]), Some("Image".into())); + assert_eq!( + pick_class(&e, &meta(), &["Image", "Doc"]), + Some("Image".into()) + ); } #[test] fn pick_class_rejects_out_of_list() { diff --git a/src-tauri/src/llm/model.rs b/src-tauri/src/llm/model.rs index 5005eb25d..a8913290a 100644 --- a/src-tauri/src/llm/model.rs +++ b/src-tauri/src/llm/model.rs @@ -40,7 +40,10 @@ pub fn download_to(spec: &ModelSpec, dest: &std::path::Path) -> Result<(), Strin let mut buf = Vec::new(); reader.read_to_end(&mut buf).map_err(|e| e.to_string())?; if !verify_sha256(&buf, spec.sha256_hex) { - return Err(format!("SHA-256 불일치 — 손상되었거나 예상과 다른 파일: {}", spec.name)); + return Err(format!( + "SHA-256 불일치 — 손상되었거나 예상과 다른 파일: {}", + spec.name + )); } std::fs::write(&part, &buf).map_err(|e| e.to_string())?; std::fs::rename(&part, dest).map_err(|e| e.to_string())?; diff --git a/src-tauri/src/llm/parse.rs b/src-tauri/src/llm/parse.rs index eccc1e471..df0f3a17c 100644 --- a/src-tauri/src/llm/parse.rs +++ b/src-tauri/src/llm/parse.rs @@ -12,7 +12,9 @@ fn extract_json(raw: &str) -> Option<&str> { depth += 1; } else if bytes[i] == b'}' { depth -= 1; - if depth == 0 { return Some(&raw[start..=i]); } + if depth == 0 { + return Some(&raw[start..=i]); + } } } None @@ -25,9 +27,17 @@ pub fn parse_verdict(raw: &str) -> Verdict { /// (판정, 이유). 실패 시 (Unrated, "")로 fail-closed. pub fn parse_verdict_full(raw: &str) -> (Verdict, String) { - let Some(js) = extract_json(raw) else { return (Verdict::Unrated, String::new()); }; - let Ok(v) = serde_json::from_str::(js) else { return (Verdict::Unrated, String::new()); }; - let reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or("").to_string(); + let Some(js) = extract_json(raw) else { + return (Verdict::Unrated, String::new()); + }; + let Ok(v) = serde_json::from_str::(js) else { + return (Verdict::Unrated, String::new()); + }; + let reason = v + .get("reason") + .and_then(|r| r.as_str()) + .unwrap_or("") + .to_string(); let verdict = match v.get("verdict").and_then(|x| x.as_str()) { Some("safe") => Verdict::Safe, Some("caution") => Verdict::Caution, @@ -61,7 +71,11 @@ pub fn parse_ext_reasoning(raw: &str, candidates: &[&str]) -> Option(js).ok()?; let type_desc = v.get("type")?.as_str()?.to_string(); - let class = v.get("class").and_then(|c| c.as_str()).filter(|c| candidates.contains(c)).map(|c| c.to_string()); + let class = v + .get("class") + .and_then(|c| c.as_str()) + .filter(|c| candidates.contains(c)) + .map(|c| c.to_string()); Some(ExtReasoning { type_desc, class }) } @@ -70,7 +84,10 @@ mod tests { use super::*; #[test] fn parses_clean_json() { - assert_eq!(parse_verdict(r#"{"verdict":"safe","reason":"cache file"}"#), Verdict::Safe); + assert_eq!( + parse_verdict(r#"{"verdict":"safe","reason":"cache file"}"#), + Verdict::Safe + ); assert_eq!(parse_verdict(r#"{"verdict":"caution"}"#), Verdict::Caution); assert_eq!(parse_verdict(r#"{"verdict":"keep"}"#), Verdict::Keep); } @@ -78,7 +95,10 @@ mod tests { fn parses_nested_json_object() { // 중첩 객체 — 안쪽 {..}가 먼저 닫혀 depth가 0이 아닌 값으로 감소하는 fall-through 경로 커버. // extract_json은 바깥 객체 전체를 반환해야 한다. - assert_eq!(parse_verdict(r#"{"verdict":"safe","meta":{"x":1}}"#), Verdict::Safe); + assert_eq!( + parse_verdict(r#"{"verdict":"safe","meta":{"x":1}}"#), + Verdict::Safe + ); } #[test] @@ -88,14 +108,23 @@ mod tests { } #[test] fn verdict_full_returns_reason() { - assert_eq!(parse_verdict_full(r#"{"verdict":"safe","reason":"tmp"}"#), (Verdict::Safe, "tmp".to_string())); + assert_eq!( + parse_verdict_full(r#"{"verdict":"safe","reason":"tmp"}"#), + (Verdict::Safe, "tmp".to_string()) + ); // reason 없으면 빈 문자열 - assert_eq!(parse_verdict_full(r#"{"verdict":"safe"}"#), (Verdict::Safe, String::new())); + assert_eq!( + parse_verdict_full(r#"{"verdict":"safe"}"#), + (Verdict::Safe, String::new()) + ); } #[test] fn unknown_verdict_value_is_unrated() { assert_eq!(parse_verdict(r#"{"verdict":"delete"}"#), Verdict::Unrated); // 알 수 없는 값 - assert_eq!(parse_verdict(r#"{"note":"no verdict field"}"#), Verdict::Unrated); // 필드 없음 + assert_eq!( + parse_verdict(r#"{"note":"no verdict field"}"#), + Verdict::Unrated + ); // 필드 없음 } #[test] fn no_braces_is_unrated() { @@ -112,32 +141,43 @@ mod tests { } #[test] fn class_pick_only_from_candidates() { - assert_eq!(parse_class_pick(r#"{"class":"Image"}"#, &["Image","Doc"]), Some("Image".into())); - assert_eq!(parse_class_pick(r#"{"class":"Video"}"#, &["Image","Doc"]), None); // 자유 생성 거부 + assert_eq!( + parse_class_pick(r#"{"class":"Image"}"#, &["Image", "Doc"]), + Some("Image".into()) + ); + assert_eq!( + parse_class_pick(r#"{"class":"Video"}"#, &["Image", "Doc"]), + None + ); // 자유 생성 거부 } #[test] fn class_pick_failure_paths_are_none() { - assert_eq!(parse_class_pick("no json", &["Image"]), None); // extract None - assert_eq!(parse_class_pick("{bad json}", &["Image"]), None); // serde err + assert_eq!(parse_class_pick("no json", &["Image"]), None); // extract None + assert_eq!(parse_class_pick("{bad json}", &["Image"]), None); // serde err assert_eq!(parse_class_pick(r#"{"other":"x"}"#, &["Image"]), None); // class 필드 없음 - assert_eq!(parse_class_pick(r#"{"class":5}"#, &["Image"]), None); // class가 문자열 아님 + assert_eq!(parse_class_pick(r#"{"class":5}"#, &["Image"]), None); // class가 문자열 아님 } #[test] fn summary_extracted_or_none() { - assert_eq!(parse_summary(r#"{"summary":"old installers"}"#), Some("old installers".into())); - assert_eq!(parse_summary("no json"), None); // extract None - assert_eq!(parse_summary("{bad}"), None); // serde err - assert_eq!(parse_summary(r#"{"x":1}"#), None); // summary 필드 없음 + assert_eq!( + parse_summary(r#"{"summary":"old installers"}"#), + Some("old installers".into()) + ); + assert_eq!(parse_summary("no json"), None); // extract None + assert_eq!(parse_summary("{bad}"), None); // serde err + assert_eq!(parse_summary(r#"{"x":1}"#), None); // summary 필드 없음 assert_eq!(parse_summary(r#"{"summary":9}"#), None); // 문자열 아님 } #[test] fn ext_reasoning_extracts_type_and_validates_class() { // class가 후보에 있으면 Some - let r = parse_ext_reasoning(r#"{"type":"3D model","class":"Model3D"}"#, &["Model3D"]).unwrap(); + let r = + parse_ext_reasoning(r#"{"type":"3D model","class":"Model3D"}"#, &["Model3D"]).unwrap(); assert_eq!(r.type_desc, "3D model"); assert_eq!(r.class.as_deref(), Some("Model3D")); // class가 후보 밖이면 type은 유지, class는 None(자유 생성 거부) - let r2 = parse_ext_reasoning(r#"{"type":"3D model","class":"Nope"}"#, &["Model3D"]).unwrap(); + let r2 = + parse_ext_reasoning(r#"{"type":"3D model","class":"Nope"}"#, &["Model3D"]).unwrap(); assert_eq!(r2.class, None); assert_eq!(r2.type_desc, "3D model"); // class:"none" → None @@ -146,9 +186,9 @@ mod tests { } #[test] fn ext_reasoning_failure_paths_are_none() { - assert!(parse_ext_reasoning("no json", &["X"]).is_none()); // extract None - assert!(parse_ext_reasoning("{bad}", &["X"]).is_none()); // serde err + assert!(parse_ext_reasoning("no json", &["X"]).is_none()); // extract None + assert!(parse_ext_reasoning("{bad}", &["X"]).is_none()); // serde err assert!(parse_ext_reasoning(r#"{"class":"X"}"#, &["X"]).is_none()); // type 필드 없음 → None - assert!(parse_ext_reasoning(r#"{"type":9}"#, &["X"]).is_none()); // type이 문자열 아님 + assert!(parse_ext_reasoning(r#"{"type":9}"#, &["X"]).is_none()); // type이 문자열 아님 } } diff --git a/src-tauri/src/llm/prompt.rs b/src-tauri/src/llm/prompt.rs index d60a4249a..2a3ac2277 100644 --- a/src-tauri/src/llm/prompt.rs +++ b/src-tauri/src/llm/prompt.rs @@ -37,7 +37,9 @@ pub fn classify_prompt(m: &FileMeta, candidates: &[&str]) -> String { Candidates: {list}\n\ Reply with ONLY this JSON (choose exactly one id from the list above):\n\ {{\"class\":\"\"}}", - name = m.name, parent = m.parent, list = candidates.join(", ") + name = m.name, + parent = m.parent, + list = candidates.join(", ") ) } @@ -69,8 +71,13 @@ pub fn ext_reason_prompt(ext: &str, candidates: &[&str]) -> String { mod tests { use super::*; fn meta() -> FileMeta { - FileMeta { path: "/downloads/old_report.pdf".into(), name: "old_report.pdf".into(), - size: 2_400_000, mtime_days: 420, parent: "downloads".into() } + FileMeta { + path: "/downloads/old_report.pdf".into(), + name: "old_report.pdf".into(), + size: 2_400_000, + mtime_days: 420, + parent: "downloads".into(), + } } #[test] fn verdict_prompt_has_metadata_and_schema() { @@ -83,7 +90,9 @@ mod tests { #[test] fn classify_prompt_lists_all_candidates_and_forbids_free_text() { let p = classify_prompt(&meta(), &["Image", "Document", "Installer"]); - for c in ["Image", "Document", "Installer"] { assert!(p.contains(c)); } + for c in ["Image", "Document", "Installer"] { + assert!(p.contains(c)); + } assert!(p.to_lowercase().contains("exactly one")); } #[test] @@ -93,8 +102,20 @@ mod tests { } #[test] fn summary_prompt_handles_multiple_samples() { - let a = FileMeta { path: "/a/x.bin".into(), name: "x.bin".into(), size: 1, mtime_days: 1, parent: "a".into() }; - let b = FileMeta { path: "/a/y.dat".into(), name: "y.dat".into(), size: 2, mtime_days: 2, parent: "a".into() }; + let a = FileMeta { + path: "/a/x.bin".into(), + name: "x.bin".into(), + size: 1, + mtime_days: 1, + parent: "a".into(), + }; + let b = FileMeta { + path: "/a/y.dat".into(), + name: "y.dat".into(), + size: 2, + mtime_days: 2, + parent: "a".into(), + }; let p = summary_prompt(&[a, b]); assert!(p.contains("x.bin") && p.contains("y.dat")); } diff --git a/src-tauri/src/llm/verdict.rs b/src-tauri/src/llm/verdict.rs index 900f7dded..392c05897 100644 --- a/src-tauri/src/llm/verdict.rs +++ b/src-tauri/src/llm/verdict.rs @@ -21,14 +21,26 @@ mod tests { use super::*; #[test] fn verdict_serde_roundtrip() { - for v in [Verdict::Safe, Verdict::Caution, Verdict::Keep, Verdict::Unrated] { + for v in [ + Verdict::Safe, + Verdict::Caution, + Verdict::Keep, + Verdict::Unrated, + ] { let s = serde_json::to_string(&v).unwrap(); assert_eq!(serde_json::from_str::(&s).unwrap(), v); } // 프런트엔드가 소문자 문자열 리터럴로 switch하므로 와이어 포맷을 고정한다. assert_eq!(serde_json::to_string(&Verdict::Safe).unwrap(), "\"safe\""); - assert_eq!(serde_json::to_string(&Verdict::Unrated).unwrap(), "\"unrated\""); - let fv = FileVerdict { path: "/a".into(), verdict: Verdict::Safe, reason: "cache".into() }; + assert_eq!( + serde_json::to_string(&Verdict::Unrated).unwrap(), + "\"unrated\"" + ); + let fv = FileVerdict { + path: "/a".into(), + verdict: Verdict::Safe, + reason: "cache".into(), + }; let s = serde_json::to_string(&fv).unwrap(); assert_eq!(serde_json::from_str::(&s).unwrap(), fv); } diff --git a/src-tauri/src/ontology.rs b/src-tauri/src/ontology.rs index 5aa03998f..4343cb4d6 100644 --- a/src-tauri/src/ontology.rs +++ b/src-tauri/src/ontology.rs @@ -105,28 +105,34 @@ pub fn parse_ttl(turtle_src: &str) -> Result { #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub enum Issue { /// C ⊑ c1, C ⊑ c2, c1 disjointWith c2 ⇒ C ⊑ owl:Nothing (sound TBox consequence of cax-dw). - UnsatisfiableClass { class: String, via_disjoint: (String, String) }, + UnsatisfiableClass { + class: String, + via_disjoint: (String, String), + }, } pub struct Reasoner { - rep: BTreeMap, // class id → equivalence representative + rep: BTreeMap, // class id → equivalence representative groups: BTreeMap>, // rep → sorted members sup: BTreeMap>, // rep → direct super-reps (acyclic after fixpoint) disjoint_pairs: Vec<(String, String)>, // raw (subject, disjointWith-object) axiom ids, captured at build time - // (target_folder is read from the Ontology, only needed by Ontology::resolve_target) + // (target_folder is read from the Ontology, only needed by Ontology::resolve_target) } impl Reasoner { pub fn build(onto: &Ontology) -> Reasoner { let ids: Vec = onto.classes.iter().map(|c| c.id.clone()).collect(); // union-find - let mut rep: BTreeMap = ids.iter().map(|i| (i.clone(), i.clone())).collect(); + let mut rep: BTreeMap = + ids.iter().map(|i| (i.clone(), i.clone())).collect(); fn find(rep: &mut BTreeMap, x: &str) -> String { // ponytail: every call site below only ever passes an id already seeded into `rep` // (either a class id from `ids`, or a `contains_key`-guarded axiom target) and `union` // only ever overwrites existing keys, so `x` is always present — no silent fallback needed. let p = rep[x].clone(); - if p == x { return p; } + if p == x { + return p; + } let r = find(rep, &p); rep.insert(x.to_string(), r.clone()); r @@ -142,7 +148,9 @@ impl Reasoner { // scm-eqc1: explicit equivalentClass pairs (only among known classes) for c in &onto.classes { for e in &c.equivalents { - if rep.contains_key(e) { union(&mut rep, &c.id, e); } + if rep.contains_key(e) { + union(&mut rep, &c.id, e); + } } } // scm-eqc2 fixpoint: collapse subClassOf cycles among representatives until none remain @@ -152,25 +160,50 @@ impl Reasoner { for c in &onto.classes { let rc = find(&mut rep, &c.id); for p in &c.parents { - if !rep.contains_key(p) { continue; } + if !rep.contains_key(p) { + continue; + } let rp = find(&mut rep, p); - if rc != rp { edges.insert((rc.clone(), rp.clone())); } + if rc != rp { + edges.insert((rc.clone(), rp.clone())); + } } } // reachability on rep graph let mut reach: BTreeMap> = BTreeMap::new(); - for (u, v) in &edges { reach.entry(u.clone()).or_default().insert(v.clone()); } - let nodes: BTreeSet = edges.iter().flat_map(|(u, v)| [u.clone(), v.clone()]).collect(); + for (u, v) in &edges { + reach.entry(u.clone()).or_default().insert(v.clone()); + } + let nodes: BTreeSet = edges + .iter() + .flat_map(|(u, v)| [u.clone(), v.clone()]) + .collect(); loop { let mut changed = false; for n in &nodes { - let outs: Vec = reach.get(n).cloned().unwrap_or_default().into_iter().collect(); + let outs: Vec = reach + .get(n) + .cloned() + .unwrap_or_default() + .into_iter() + .collect(); for m in outs { - let ms: Vec = reach.get(&m).cloned().unwrap_or_default().into_iter().collect(); - for t in ms { if reach.entry(n.clone()).or_default().insert(t) { changed = true; } } + let ms: Vec = reach + .get(&m) + .cloned() + .unwrap_or_default() + .into_iter() + .collect(); + for t in ms { + if reach.entry(n.clone()).or_default().insert(t) { + changed = true; + } + } } } - if !changed { break; } + if !changed { + break; + } } // find a mutual-reachability pair (cycle) and union it let mut merged = false; @@ -183,7 +216,9 @@ impl Reasoner { } } } - if !merged { break; } + if !merged { + break; + } } // groups let mut groups: BTreeMap> = BTreeMap::new(); @@ -191,15 +226,22 @@ impl Reasoner { let r = find(&mut rep, id); groups.entry(r).or_default().push(id.clone()); } - for m in groups.values_mut() { m.sort(); m.dedup(); } + for m in groups.values_mut() { + m.sort(); + m.dedup(); + } // super-reps (direct), acyclic let mut sup: BTreeMap> = BTreeMap::new(); for c in &onto.classes { let rc = find(&mut rep, &c.id); for p in &c.parents { - if !rep.contains_key(p) { continue; } + if !rep.contains_key(p) { + continue; + } let rp = find(&mut rep, p); - if rc != rp { sup.entry(rc.clone()).or_default().insert(rp); } + if rc != rp { + sup.entry(rc.clone()).or_default().insert(rp); + } } } // raw disjointWith axiom pairs, captured for check_coherence (no Ontology re-access needed) @@ -209,10 +251,17 @@ impl Reasoner { disjoint_pairs.push((c.id.clone(), d.clone())); } } - Reasoner { rep, groups, sup, disjoint_pairs } + Reasoner { + rep, + groups, + sup, + disjoint_pairs, + } } - fn rep_of(&self, id: &str) -> Option { self.rep.get(id).cloned() } + fn rep_of(&self, id: &str) -> Option { + self.rep.get(id).cloned() + } /// reps reachable from `r` (excl. self), transitive. fn closure(&self, r: &str) -> BTreeSet { @@ -228,23 +277,33 @@ impl Reasoner { /// All (proper + improper via equivalents) superclass ids of `class_id`, sorted. pub fn ancestors(&self, class_id: &str) -> Vec { - let Some(r) = self.rep_of(class_id) else { return Vec::new() }; + let Some(r) = self.rep_of(class_id) else { + return Vec::new(); + }; let mut reps = self.closure(&r); reps.insert(r); // scm-cls reflexive let mut out: BTreeSet = BTreeSet::new(); - for rep in reps { out.extend(self.groups.get(&rep).into_iter().flatten().cloned()); } + for rep in reps { + out.extend(self.groups.get(&rep).into_iter().flatten().cloned()); + } out.into_iter().collect() } /// Members equivalent to `class_id` (its group), sorted. pub fn equivalents(&self, class_id: &str) -> Vec { - self.rep_of(class_id).and_then(|r| self.groups.get(&r).cloned()).unwrap_or_default() + self.rep_of(class_id) + .and_then(|r| self.groups.get(&r).cloned()) + .unwrap_or_default() } /// Equivalence groups (size > 1), including both explicit equivalentClass groups /// and subClassOf-cycle folds. Advisory only. pub fn cycle_equivalences(&self) -> Vec> { - self.groups.values().filter(|g| g.len() > 1).cloned().collect() + self.groups + .values() + .filter(|g| g.len() > 1) + .cloned() + .collect() } /// Unsatisfiable classes (cax-dw TBox consequence). Empty = coherent. @@ -258,12 +317,20 @@ impl Reasoner { } let mut out: Vec = Vec::new(); for c_id in self.rep.keys() { - let Some(cr) = self.rep_of(c_id) else { continue }; + let Some(cr) = self.rep_of(c_id) else { + continue; + }; let mut clo = self.closure(&cr); clo.insert(cr); // incl self - // C unsat iff some disjoint pair has BOTH reps in C's closure (covers ra==rb corner) - if let Some((_, _, a, b)) = dis.iter().find(|(ra, rb, _, _)| clo.contains(ra) && clo.contains(rb)) { - out.push(Issue::UnsatisfiableClass { class: c_id.clone(), via_disjoint: (a.clone(), b.clone()) }); + // C unsat iff some disjoint pair has BOTH reps in C's closure (covers ra==rb corner) + if let Some((_, _, a, b)) = dis + .iter() + .find(|(ra, rb, _, _)| clo.contains(ra) && clo.contains(rb)) + { + out.push(Issue::UnsatisfiableClass { + class: c_id.clone(), + via_disjoint: (a.clone(), b.clone()), + }); } } out @@ -271,7 +338,9 @@ impl Reasoner { } impl Ontology { - fn reasoner(&self) -> Reasoner { Reasoner::build(self) } + fn reasoner(&self) -> Reasoner { + Reasoner::build(self) + } /// targetFolder from the nearest ancestor/equivalent (BFS hops; ties by ascending class id). pub fn resolve_target(&self, class_id: &str) -> Option { @@ -289,7 +358,10 @@ impl Ontology { while let Some(u) = q.pop_front() { let d = dist[&u]; for v in r.sup.get(&u).into_iter().flatten() { - if !dist.contains_key(v) { dist.insert(v.clone(), d + 1); q.push_back(v.clone()); } + if !dist.contains_key(v) { + dist.insert(v.clone(), d + 1); + q.push_back(v.clone()); + } } } // candidates: classes with a target whose rep is reachable; pick min (dist, id) @@ -299,7 +371,13 @@ impl Ontology { let Some(cr) = r.rep_of(&c.id) else { continue }; let Some(&d) = dist.get(&cr) else { continue }; let cand = (d, c.id.clone(), t.clone()); - if best.as_ref().map(|b| (cand.0, &cand.1) < (b.0, &b.1)).unwrap_or(true) { best = Some(cand); } + if best + .as_ref() + .map(|b| (cand.0, &cand.1) < (b.0, &b.1)) + .unwrap_or(true) + { + best = Some(cand); + } } best.map(|(_, _, t)| t) } @@ -345,11 +423,19 @@ dm:B a owl:Class ; #[test] fn parses_classes_labels_parents_and_targets() { let onto = parse_ttl(SAMPLE).unwrap(); - let doc = onto.classes.iter().find(|c| c.id.ends_with("Document")).unwrap(); + let doc = onto + .classes + .iter() + .find(|c| c.id.ends_with("Document")) + .unwrap(); assert!(doc.parents.is_empty()); assert_eq!(doc.target_folder.as_deref(), Some("~/Documents/{class}")); assert!(!doc.label.is_empty()); - let rcpt = onto.classes.iter().find(|c| c.id.ends_with("Receipt")).unwrap(); + let rcpt = onto + .classes + .iter() + .find(|c| c.id.ends_with("Receipt")) + .unwrap(); assert!(rcpt.parents.iter().any(|p| p.ends_with("Document"))); assert_eq!(rcpt.target_folder, None); } @@ -367,7 +453,10 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B ; let onto = parse_ttl(ttl).unwrap(); let c = onto.classes.iter().find(|c| c.id.ends_with("#C")).unwrap(); assert_eq!(c.parents.len(), 2); - assert!(c.parents.iter().any(|p| p.ends_with("#A")) && c.parents.iter().any(|p| p.ends_with("#B"))); + assert!( + c.parents.iter().any(|p| p.ends_with("#A")) + && c.parents.iter().any(|p| p.ends_with("#B")) + ); assert!(c.equivalents.iter().any(|e| e.ends_with("#P"))); assert!(c.disjoints.iter().any(|d| d.ends_with("#Q"))); } @@ -384,15 +473,27 @@ dm:A a owl:Class . dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm: "#; let onto = parse_ttl(ttl).unwrap(); let c = onto.classes.iter().find(|c| c.id.ends_with("#C")).unwrap(); - assert_eq!(c.parents.len(), 1, "중복 subClassOf 오브젝트는 한 번만 채택, 리터럴 오브젝트는 무시"); + assert_eq!( + c.parents.len(), + 1, + "중복 subClassOf 오브젝트는 한 번만 채택, 리터럴 오브젝트는 무시" + ); } #[test] fn resolve_target_inherits_from_ancestor() { let onto = parse_ttl(SAMPLE).unwrap(); - let rcpt_id = &onto.classes.iter().find(|c| c.id.ends_with("Receipt")).unwrap().id; + let rcpt_id = &onto + .classes + .iter() + .find(|c| c.id.ends_with("Receipt")) + .unwrap() + .id; // Receipt는 자체 targetFolder 없음 → Document의 것 상속 - assert_eq!(onto.resolve_target(rcpt_id).as_deref(), Some("~/Documents/{class}")); + assert_eq!( + onto.resolve_target(rcpt_id).as_deref(), + Some("~/Documents/{class}") + ); } #[test] @@ -445,36 +546,63 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . let ttl = include_str!("../resources/ontology/default.ttl"); let onto = parse_ttl(ttl).unwrap(); let find = |suffix: &str| { - onto.classes.iter().find(|c| c.id.ends_with(suffix)).map(|c| c.id.clone()) + onto.classes + .iter() + .find(|c| c.id.ends_with(suffix)) + .map(|c| c.id.clone()) }; // Receipt → Document의 폴더 상속 let receipt = find("Receipt").unwrap(); - assert_eq!(onto.resolve_target(&receipt).as_deref(), Some("~/Documents/{class}")); + assert_eq!( + onto.resolve_target(&receipt).as_deref(), + Some("~/Documents/{class}") + ); // Image → Media의 폴더 상속 let image = find("Image").unwrap(); - assert_eq!(onto.resolve_target(&image).as_deref(), Some("~/Media/{class}")); + assert_eq!( + onto.resolve_target(&image).as_deref(), + Some("~/Media/{class}") + ); // Installer는 자체 폴더 let installer = find("Installer").unwrap(); - assert_eq!(onto.resolve_target(&installer).as_deref(), Some("~/Installers")); + assert_eq!( + onto.resolve_target(&installer).as_deref(), + Some("~/Installers") + ); } #[test] fn resolve_target_none_when_parent_chain_cycles() { // targetFolder가 없는 상호 순환 subClassOf — 최대 깊이 방어가 None으로 종료되어야 함 let onto = parse_ttl(CYCLE).unwrap(); - let a_id = &onto.classes.iter().find(|c| c.id.ends_with('A')).unwrap().id; + let a_id = &onto + .classes + .iter() + .find(|c| c.id.ends_with('A')) + .unwrap() + .id; assert_eq!(onto.resolve_target(a_id), None); } - fn onto(ttl: &str) -> Ontology { parse_ttl(ttl).unwrap() } + fn onto(ttl: &str) -> Ontology { + parse_ttl(ttl).unwrap() + } const PRE: &str = "@prefix owl: .\n@prefix rdfs: .\n@prefix dm: .\n"; - fn ends<'a>(v: &'a [String], suf: &str) -> bool { v.iter().any(|x| x.ends_with(suf)) } + fn ends<'a>(v: &'a [String], suf: &str) -> bool { + v.iter().any(|x| x.ends_with(suf)) + } #[test] fn transitive_ancestors_across_multiple_parents() { let o = onto(&format!("{PRE}dm:A a owl:Class ; rdfs:subClassOf dm:B , dm:C .\ndm:B a owl:Class ; rdfs:subClassOf dm:D .\ndm:C a owl:Class .\ndm:D a owl:Class .\n")); let r = Reasoner::build(&o); - let a = o.classes.iter().find(|c| c.id.ends_with("#A")).unwrap().id.clone(); + let a = o + .classes + .iter() + .find(|c| c.id.ends_with("#A")) + .unwrap() + .id + .clone(); let anc = r.ancestors(&a); assert!(ends(&anc, "#B") && ends(&anc, "#C") && ends(&anc, "#D")); } @@ -484,7 +612,13 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . // A ≡ B, B ⊑ C(target) ⇒ A inherits C's folder (scm-eqc1 + scm-sco) let o = onto(&format!("{PRE}dm:A a owl:Class ; owl:equivalentClass dm:B .\ndm:B a owl:Class ; rdfs:subClassOf dm:C .\ndm:C a owl:Class ; dm:targetFolder \"~/C\" .\n")); let r = Reasoner::build(&o); - let a = o.classes.iter().find(|c| c.id.ends_with("#A")).unwrap().id.clone(); + let a = o + .classes + .iter() + .find(|c| c.id.ends_with("#A")) + .unwrap() + .id + .clone(); assert!(ends(&r.equivalents(&a), "#B")); assert!(ends(&r.ancestors(&a), "#C")); assert_eq!(o.resolve_target(&a).as_deref(), Some("~/C")); @@ -495,7 +629,13 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . // scm-eqc2: A ⊑ B ⊑ A ⇒ equivalent, coherent, resolve terminates let o = onto(&format!("{PRE}dm:A a owl:Class ; rdfs:subClassOf dm:B .\ndm:B a owl:Class ; rdfs:subClassOf dm:A .\n")); let r = Reasoner::build(&o); - let a = o.classes.iter().find(|c| c.id.ends_with("#A")).unwrap().id.clone(); + let a = o + .classes + .iter() + .find(|c| c.id.ends_with("#A")) + .unwrap() + .id + .clone(); assert!(ends(&r.equivalents(&a), "#B")); assert!(r.check_coherence().is_empty()); assert_eq!(o.resolve_target(&a), None); @@ -506,7 +646,13 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . // A ≡ B, B ⊑ C, C ⊑ A ⇒ {A,B,C} equivalent (merge exposes 2nd-round SCC) let o = onto(&format!("{PRE}dm:A a owl:Class ; owl:equivalentClass dm:B .\ndm:B a owl:Class ; rdfs:subClassOf dm:C .\ndm:C a owl:Class ; rdfs:subClassOf dm:A .\n")); let r = Reasoner::build(&o); - let a = o.classes.iter().find(|c| c.id.ends_with("#A")).unwrap().id.clone(); + let a = o + .classes + .iter() + .find(|c| c.id.ends_with("#A")) + .unwrap() + .id + .clone(); let eq = r.equivalents(&a); assert!(ends(&eq, "#B") && ends(&eq, "#C")); assert!(r.check_coherence().is_empty()); @@ -518,7 +664,9 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . let o = onto(&format!("{PRE}dm:A a owl:Class ; owl:disjointWith dm:B .\ndm:B a owl:Class .\ndm:C a owl:Class ; rdfs:subClassOf dm:A , dm:B .\n")); let r = Reasoner::build(&o); let issues = r.check_coherence(); - assert!(issues.iter().any(|i| matches!(i, Issue::UnsatisfiableClass { class, .. } if class.ends_with("#C")))); + assert!(issues.iter().any( + |i| matches!(i, Issue::UnsatisfiableClass { class, .. } if class.ends_with("#C")) + )); } #[test] @@ -527,8 +675,12 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . let o = onto(&format!("{PRE}dm:A a owl:Class ; owl:equivalentClass dm:B ; owl:disjointWith dm:B .\ndm:B a owl:Class .\ndm:D a owl:Class ; owl:disjointWith dm:D .\n")); let r = Reasoner::build(&o); let issues = r.check_coherence(); - assert!(issues.iter().any(|i| matches!(i, Issue::UnsatisfiableClass { class, .. } if class.ends_with("#A")))); - assert!(issues.iter().any(|i| matches!(i, Issue::UnsatisfiableClass { class, .. } if class.ends_with("#D")))); + assert!(issues.iter().any( + |i| matches!(i, Issue::UnsatisfiableClass { class, .. } if class.ends_with("#A")) + )); + assert!(issues.iter().any( + |i| matches!(i, Issue::UnsatisfiableClass { class, .. } if class.ends_with("#D")) + )); } #[test] @@ -541,7 +693,13 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . fn resolve_target_nearest_first_id_tiebreak() { // C ⊑ A(~/A), C ⊑ B(~/B): both distance-1 ⇒ id-tiebreak picks A let o = onto(&format!("{PRE}dm:A a owl:Class ; dm:targetFolder \"~/A\" .\ndm:B a owl:Class ; dm:targetFolder \"~/B\" .\ndm:C a owl:Class ; rdfs:subClassOf dm:A , dm:B .\n")); - let c = o.classes.iter().find(|c| c.id.ends_with("#C")).unwrap().id.clone(); + let c = o + .classes + .iter() + .find(|c| c.id.ends_with("#C")) + .unwrap() + .id + .clone(); assert_eq!(o.resolve_target(&c).as_deref(), Some("~/A")); } @@ -549,7 +707,10 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . fn cycle_equivalences_reports_merged_groups() { let o = onto(&format!("{PRE}dm:A a owl:Class ; rdfs:subClassOf dm:B .\ndm:B a owl:Class ; rdfs:subClassOf dm:A .\ndm:X a owl:Class .\n")); let r = Reasoner::build(&o); - assert!(r.cycle_equivalences().iter().any(|g| g.len() == 2 && ends(g, "#A") && ends(g, "#B"))); + assert!(r + .cycle_equivalences() + .iter() + .any(|g| g.len() == 2 && ends(g, "#A") && ends(g, "#B"))); } #[test] @@ -557,7 +718,13 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . // subClassOf / equivalentClass / disjointWith → never-declared classes: must be skipped, no panic (spec §7) let o = onto(&format!("{PRE}dm:A a owl:Class ; rdfs:subClassOf dm:Ghost ; owl:equivalentClass dm:Phantom ; owl:disjointWith dm:Specter .\n")); let r = Reasoner::build(&o); - let a = o.classes.iter().find(|c| c.id.ends_with("#A")).unwrap().id.clone(); + let a = o + .classes + .iter() + .find(|c| c.id.ends_with("#A")) + .unwrap() + .id + .clone(); // A has no known supers/equivalents beyond itself; nothing panics; ontology is coherent assert!(r.ancestors(&a).iter().any(|x| x.ends_with("#A"))); // scm-cls self assert!(r.check_coherence().is_empty()); @@ -572,7 +739,13 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . // check that redundant declarations don't fragment or duplicate the equivalence group. let o = onto(&format!("{PRE}dm:A a owl:Class ; owl:equivalentClass dm:B .\ndm:B a owl:Class ; owl:equivalentClass dm:A .\n")); let r = Reasoner::build(&o); - let a = o.classes.iter().find(|c| c.id.ends_with("#A")).unwrap().id.clone(); + let a = o + .classes + .iter() + .find(|c| c.id.ends_with("#A")) + .unwrap() + .id + .clone(); let eq = r.equivalents(&a); assert_eq!(eq.len(), 2); assert!(ends(&eq, "#A") && ends(&eq, "#B")); diff --git a/src-tauri/src/organize.rs b/src-tauri/src/organize.rs index 78abe649f..5ce4cba8f 100644 --- a/src-tauri/src/organize.rs +++ b/src-tauri/src/organize.rs @@ -37,22 +37,29 @@ pub fn plan_moves_with( for f in files { // filename을 classify보다 먼저 확인 — 파일명 없는 경로(루트 등)는 여기서 걸러진다. // (classify 뒤에 두면 이 분기가 도달 불가라 커버리지 사각이 됨) - let Some(name) = f.path.file_name() else { continue }; + let Some(name) = f.path.file_name() else { + continue; + }; let age_days = now_ms.saturating_sub(f.mtime_ms) / 86_400_000; // precedence: 사용자 규칙 → picker(LLM) → 확장자 classify → 제외 - let local: String = match crate::userrules::classify_by_rules(rules, &f.path, f.size, age_days) { - Some(c) => c, - None => match pick(&f.path, &candidates) { - Some(picked) => picked, - None => match classify(&f.path) { - Some(c) => c.to_string(), - None => continue, + let local: String = + match crate::userrules::classify_by_rules(rules, &f.path, f.size, age_days) { + Some(c) => c, + None => match pick(&f.path, &candidates) { + Some(picked) => picked, + None => match classify(&f.path) { + Some(c) => c.to_string(), + None => continue, + }, }, - }, - }; + }; // 로컬명 → 온톨로지 클래스 - let Some(class) = onto.classes.iter().find(|c| local_name(&c.id) == local) else { continue }; - let Some(template) = onto.resolve_target_with(&reasoner, &class.id) else { continue }; + let Some(class) = onto.classes.iter().find(|c| local_name(&c.id) == local) else { + continue; + }; + let Some(template) = onto.resolve_target_with(&reasoner, &class.id) else { + continue; + }; // 템플릿 치환: ~ → home, {class} → 로컬명 let folder = template .replacen('~', &home.to_string_lossy(), 1) @@ -92,11 +99,19 @@ dm:Installer a owl:Class ; rdfs:label "설치파일"@ko ; dm:targetFolder "~/Ins "#; fn fe(p: &str, size: u64) -> FileEntry { - FileEntry { path: PathBuf::from(p), size, mtime_ms: 0 } + 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 } + FileEntry { + path: PathBuf::from(p), + size, + mtime_ms, + } } #[test] @@ -118,8 +133,8 @@ dm:Installer a owl:Class ; rdfs:label "설치파일"@ko ; dm:targetFolder "~/Ins 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 없음 → 제외 + fe("/x/unknown.xyz", 10), // 미분류 → 제외 + fe("/x/main.rs", 20), // Code: targetFolder 없음 → 제외 ]; assert!(plan_moves(&files, &onto, home).is_empty()); } @@ -227,15 +242,23 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ let onto = parse_ttl(ONTO).unwrap(); let home = Path::new("/home/u"); let rules = vec![crate::userrules::Rule { - r#match: crate::userrules::RuleMatch { ext: Some("png".into()), name_contains: None, path_contains: None, min_size: None, max_size: None, min_age_days: None, max_age_days: None }, + r#match: crate::userrules::RuleMatch { + ext: Some("png".into()), + name_contains: None, + path_contains: None, + min_size: None, + max_size: None, + min_age_days: None, + max_age_days: None, + }, 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); assert_eq!(plans.len(), 1); assert!(plans[0].class_id.ends_with("Installer")); // 규칙이 picker를 이긴다 - // 규칙이 우선하므로 plan_moves_with 내부에서 pick은 호출되지 않는다(설계상 의도). - // 라인 커버리지 확보를 위해 클로저 자체가 유효한 picker임을 별도로 확인. + // 규칙이 우선하므로 plan_moves_with 내부에서 pick은 호출되지 않는다(설계상 의도). + // 라인 커버리지 확보를 위해 클로저 자체가 유효한 picker임을 별도로 확인. assert_eq!(pick(Path::new("/x"), &[]), Some("Image".to_string())); } @@ -245,7 +268,15 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ let onto = parse_ttl(ONTO).unwrap(); let home = Path::new("/home/u"); let rules = vec![crate::userrules::Rule { - r#match: crate::userrules::RuleMatch { ext: Some("iso".into()), name_contains: None, path_contains: None, min_size: None, max_size: None, min_age_days: None, max_age_days: None }, + r#match: crate::userrules::RuleMatch { + ext: Some("iso".into()), + name_contains: None, + path_contains: None, + min_size: None, + max_size: None, + min_age_days: None, + max_age_days: None, + }, class: "Installer".into(), }]; let pick = |_p: &Path, _c: &[&str]| None; @@ -261,16 +292,38 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ let home = Path::new("/home/u"); let now = 100 * 86_400_000u64; let rules = vec![crate::userrules::Rule { - r#match: crate::userrules::RuleMatch { ext: None, name_contains: None, path_contains: None, min_size: None, max_size: None, min_age_days: Some(30), max_age_days: None }, + r#match: crate::userrules::RuleMatch { + ext: None, + name_contains: None, + path_contains: None, + min_size: None, + max_size: None, + min_age_days: Some(30), + max_age_days: None, + }, class: "Installer".into(), }]; 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( + &[fe_at("/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( + &[fe_at("/d/pic.png", 10, now)], + &onto, + home, + now, + &rules, + &pick, + ); assert_eq!(fresh.len(), 1); assert!(fresh[0].class_id.ends_with("Image")); } @@ -284,11 +337,26 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ let now = 100 * 86_400_000u64; let future = 200 * 86_400_000u64; // mtime in the future relative to now let rules = vec![crate::userrules::Rule { - r#match: crate::userrules::RuleMatch { ext: None, name_contains: None, path_contains: None, min_size: None, max_size: None, min_age_days: Some(1), max_age_days: None }, + r#match: crate::userrules::RuleMatch { + ext: None, + name_contains: None, + path_contains: None, + min_size: None, + max_size: None, + min_age_days: Some(1), + max_age_days: None, + }, 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( + &[fe_at("/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/reasoning.rs b/src-tauri/src/reasoning.rs index 28dbb02ce..3eed683a2 100644 --- a/src-tauri/src/reasoning.rs +++ b/src-tauri/src/reasoning.rs @@ -13,7 +13,12 @@ pub struct ExtInsight { pub fn distinct_extensions(samples: &[String]) -> Vec { let mut exts: Vec = samples .iter() - .filter_map(|p| std::path::Path::new(p).extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase())) + .filter_map(|p| { + std::path::Path::new(p) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + }) .collect(); exts.sort(); exts.dedup(); @@ -24,7 +29,11 @@ pub fn distinct_extensions(samples: &[String]) -> Vec { pub fn merge_insight(ext: &str, llm: Option, web: Option) -> ExtInsight { let (llm_type, suggested_class) = match &llm { Some(r) => ( - if r.type_desc.is_empty() { None } else { Some(r.type_desc.clone()) }, + if r.type_desc.is_empty() { + None + } else { + Some(r.type_desc.clone()) + }, r.class.clone(), ), None => (None, None), @@ -34,9 +43,15 @@ pub fn merge_insight(ext: &str, llm: Option, web: Option) (false, true) => "web", (true, false) => "llm", (false, false) => "none", - }.to_string(); + } + .to_string(); let type_desc = web.or(llm_type); // 웹 우선 - ExtInsight { ext: ext.to_string(), type_desc, suggested_class, source } + ExtInsight { + ext: ext.to_string(), + type_desc, + suggested_class, + source, + } } /// 확장자별 오프라인 추론 + (online일 때만) 웹 조회 병합. web=None이면 웹 분기 절대 미실행(default offline). @@ -60,12 +75,23 @@ mod tests { #[test] fn distinct_extensions_lowercased_sorted_deduped() { - let s = vec!["/a/x.FBX".into(), "/b/y.fbx".into(), "/c/z.parquet".into(), "/d/noext".into()]; - assert_eq!(distinct_extensions(&s), vec!["fbx".to_string(), "parquet".to_string()]); + let s = vec![ + "/a/x.FBX".into(), + "/b/y.fbx".into(), + "/c/z.parquet".into(), + "/d/noext".into(), + ]; + assert_eq!( + distinct_extensions(&s), + vec!["fbx".to_string(), "parquet".to_string()] + ); } #[test] fn merge_prefers_web_type_keeps_llm_class() { - let llm = Some(ExtReasoning { type_desc: "3D model".into(), class: Some("Model3D".into()) }); + let llm = Some(ExtReasoning { + type_desc: "3D model".into(), + class: Some("Model3D".into()), + }); let ins = merge_insight("fbx", llm, Some("Autodesk FBX 3D format".into())); assert_eq!(ins.type_desc.as_deref(), Some("Autodesk FBX 3D format")); // 웹 우선 assert_eq!(ins.suggested_class.as_deref(), Some("Model3D")); @@ -73,9 +99,15 @@ mod tests { } #[test] fn merge_llm_only_and_web_only_and_none() { - let llm = Some(ExtReasoning { type_desc: "data".into(), class: None }); + let llm = Some(ExtReasoning { + type_desc: "data".into(), + class: None, + }); assert_eq!(merge_insight("dat", llm, None).source, "llm"); - assert_eq!(merge_insight("dat", None, Some("desc".into())).source, "web"); + assert_eq!( + merge_insight("dat", None, Some("desc".into())).source, + "web" + ); let none = merge_insight("dat", None, None); assert_eq!(none.source, "none"); assert_eq!(none.type_desc, None); @@ -83,7 +115,12 @@ mod tests { #[test] fn build_insights_offline_never_calls_web() { // web=None → 웹 클로저가 없으므로 호출 자체가 불가능(프라이버시: default offline) - let reason = |e: &str| Some(ExtReasoning { type_desc: format!("t-{e}"), class: None }); + let reason = |e: &str| { + Some(ExtReasoning { + type_desc: format!("t-{e}"), + class: None, + }) + }; let out = build_insights(&["fbx".into()], &reason, None); assert_eq!(out[0].source, "llm"); assert_eq!(out[0].type_desc.as_deref(), Some("t-fbx")); @@ -91,7 +128,10 @@ mod tests { #[test] fn merge_llm_with_empty_type_desc_yields_no_type_but_keeps_class() { // LLM이 type을 "none"으로 답해 빈 문자열로 파싱된 경우 — type_desc는 None, class 제안은 유지 - let llm = Some(ExtReasoning { type_desc: "".into(), class: Some("Model3D".into()) }); + let llm = Some(ExtReasoning { + type_desc: "".into(), + class: Some("Model3D".into()), + }); let ins = merge_insight("fbx", llm, None); assert_eq!(ins.type_desc, None); assert_eq!(ins.suggested_class.as_deref(), Some("Model3D")); @@ -101,7 +141,10 @@ mod tests { fn build_insights_online_receives_only_ext_token() { // 프라이버시: 웹 클로저에 넘어오는 값은 확장자 토큰뿐(경로 구분자 없음) let reason = |_: &str| None; - let web = |e: &str| { assert!(!e.contains('/') && !e.contains('.')); Some(format!("web-{e}")) }; + let web = |e: &str| { + assert!(!e.contains('/') && !e.contains('.')); + Some(format!("web-{e}")) + }; let out = build_insights(&["parquet".into()], &reason, Some(&web)); assert_eq!(out[0].source, "web"); assert_eq!(out[0].type_desc.as_deref(), Some("web-parquet")); diff --git a/src-tauri/src/rules.rs b/src-tauri/src/rules.rs index 2fa6cebbf..3be2d448c 100644 --- a/src-tauri/src/rules.rs +++ b/src-tauri/src/rules.rs @@ -21,7 +21,11 @@ impl BaseDirs { let local_data = std::env::var("LOCALAPPDATA").map(PathBuf::from).ok()?; #[cfg(not(windows))] let local_data = home.join(".cache"); - Some(BaseDirs { temp, local_data, home }) + Some(BaseDirs { + temp, + local_data, + home, + }) } } @@ -58,8 +62,11 @@ fn catalog(bases: &BaseDirs) -> Vec<(&'static str, &'static str, PathBuf)> { ("os-temp", "OS 임시 폴더", bases.temp.clone()), ("npm-cache", "npm 캐시", npm), ("pip-cache", "pip 캐시", pip), - ("cargo-registry-cache", "cargo 레지스트리 캐시", - bases.home.join(".cargo").join("registry").join("cache")), + ( + "cargo-registry-cache", + "cargo 레지스트리 캐시", + bases.home.join(".cargo").join("registry").join("cache"), + ), ]; // Windows 진단 캐시 — 조용히 수십 GB로 자라는 것들. RDP 자동 추적(RdClientAutoTrace)의 .etl 로그가 @@ -67,12 +74,25 @@ fn catalog(bases: &BaseDirs) -> Vec<(&'static str, &'static str, PathBuf)> { // 사용자가 크기를 보고 그것만 콕 집어 정리하게 한다. WER/CrashDumps도 동류의 진단 산출물. #[cfg(windows)] entries.extend([ - ("rdp-autotrace", "원격 데스크톱 추적 로그", - bases.temp.join("DiagOutputDir").join("RdClientAutoTrace")), - ("windows-crashdumps", "앱 크래시 덤프", - bases.local_data.join("CrashDumps")), - ("windows-wer", "Windows 오류 보고 (WER)", - bases.local_data.join("Microsoft").join("Windows").join("WER")), + ( + "rdp-autotrace", + "원격 데스크톱 추적 로그", + bases.temp.join("DiagOutputDir").join("RdClientAutoTrace"), + ), + ( + "windows-crashdumps", + "앱 크래시 덤프", + bases.local_data.join("CrashDumps"), + ), + ( + "windows-wer", + "Windows 오류 보고 (WER)", + bases + .local_data + .join("Microsoft") + .join("Windows") + .join("WER"), + ), ]); entries @@ -88,7 +108,9 @@ pub fn cache_candidates(bases: &BaseDirs) -> Vec { // UX가 문제 되면 candidates에 취소 토큰과 진행 이벤트를 추가. // interval 1: 진행 콜백(no-op)이 작은 테스트 픽스처에서도 실행되어 커버리지에서 // 0으로 남지 않음 — 콜백이 아무 일도 하지 않으므로 호출 빈도는 동작에 무관 - scanner::scan_dir_with_interval(&path, &AtomicBool::new(false), 1, |_| {}).stats.bytes + scanner::scan_dir_with_interval(&path, &AtomicBool::new(false), 1, |_| {}) + .stats + .bytes } else { 0 }; @@ -111,7 +133,9 @@ pub fn is_catalog_path(bases: &BaseDirs, dir: &Path) -> bool { /// 캐시 디렉토리 자체는 보존하고 내용물만 비우기 위한 직계 자식 열거. /// 심링크는 제외 — 이 코드베이스의 모든 순회와 동일한 방어 (scanner keep_entry, node_view 참조) pub fn clean_targets(dir: &Path) -> Vec { - let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() }; + let Ok(rd) = std::fs::read_dir(dir) else { + return Vec::new(); + }; rd.filter_map(|e| e.ok()) .filter(|e| e.file_type().map(|t| !t.is_symlink()).unwrap_or(false)) .map(|e| e.path()) @@ -144,7 +168,11 @@ mod tests { let bases = fake_bases(tmp.path()); // npm 캐시만 실제로 만들어 둔다 (한 줄: 각 arm이 별도 라인이면 플랫폼별로 반대쪽이 // 영구 미커버로 남는다 — is_protected의 home 변수명 선택과 동일한 관례) - let npm = if cfg!(windows) { bases.local_data.join("npm-cache") } else { bases.home.join(".npm") }; + let npm = if cfg!(windows) { + bases.local_data.join("npm-cache") + } else { + bases.home.join(".npm") + }; fs::create_dir_all(&npm).unwrap(); fs::write(npm.join("blob.bin"), vec![0u8; 128]).unwrap(); @@ -209,7 +237,8 @@ mod tests { fn clean_targets_excludes_symlinks() { let tmp = tempfile::tempdir().unwrap(); fs::write(tmp.path().join("real.bin"), b"x").unwrap(); - std::os::unix::fs::symlink(tmp.path().join("real.bin"), tmp.path().join("link.bin")).unwrap(); + std::os::unix::fs::symlink(tmp.path().join("real.bin"), tmp.path().join("link.bin")) + .unwrap(); let names: Vec = clean_targets(tmp.path()) .iter() .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) diff --git a/src-tauri/src/safety.rs b/src-tauri/src/safety.rs index 1a581b504..dd6f841e0 100644 --- a/src-tauri/src/safety.rs +++ b/src-tauri/src/safety.rs @@ -99,11 +99,18 @@ pub fn is_protected(path: &Path) -> bool { // macOS는 extend로 시스템 경로를 더 넣는다 — 다른 unix에선 그 라인이 cfg-out되어 mut가 // 미사용이므로 allow(unused_mut). Linux 게이트는 macOS 전용 라인을 컴파일하지 않아 커버 불필요. #[allow(unused_mut)] - let mut denied_prefixes: Vec<&str> = - vec!["/usr", "/etc", "/bin", "/sbin", "/lib", "/boot", "/proc", "/sys", "/dev"]; + let mut denied_prefixes: Vec<&str> = vec![ + "/usr", "/etc", "/bin", "/sbin", "/lib", "/boot", "/proc", "/sys", "/dev", + ]; #[cfg(target_os = "macos")] denied_prefixes.extend_from_slice(&[ - "/System", "/Library", "/Applications", "/private", "/Volumes", "/cores", "/Network", + "/System", + "/Library", + "/Applications", + "/private", + "/Volumes", + "/cores", + "/Network", ]); let s = path.to_string_lossy(); if denied_prefixes @@ -164,7 +171,9 @@ pub fn journal_append(journal_path: &Path, entry: &JournalEntry) -> Result<(), S } pub fn journal_recent(journal_path: &Path, limit: usize) -> Vec { - let Ok(content) = std::fs::read_to_string(journal_path) else { return Vec::new() }; + let Ok(content) = std::fs::read_to_string(journal_path) else { + return Vec::new(); + }; let mut entries: Vec = content .lines() .filter_map(|l| serde_json::from_str(l).ok()) @@ -180,7 +189,9 @@ pub fn journal_recent(journal_path: &Path, limit: usize) -> Vec { fn strip_verbatim(p: &Path) -> PathBuf { use std::path::{Component, Prefix}; let mut comps = p.components(); - let Some(Component::Prefix(pr)) = comps.next() else { return p.to_path_buf() }; + let Some(Component::Prefix(pr)) = comps.next() else { + return p.to_path_buf(); + }; match pr.kind() { Prefix::VerbatimDisk(d) => { let mut out = PathBuf::from(format!("{}:\\", d as char)); @@ -241,12 +252,16 @@ pub fn trash_delete( now_ms: u64, ) -> Result<(), SafetyError> { // '..'는 lexical 가드를 우회해 보호 경로 밖으로 보이게 할 수 있음 — 컴포넌트 단위로 먼저 거부 - if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + if path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { return Err(SafetyError::Protected(path.to_path_buf())); } // 가드는 정규화된 경로로 판정. canonicalize 실패(예: 이미 사라진 경로)면 // lexical 경로로 판정한다 (ParentDir는 위에서 이미 거부됨) — 어느 쪽이든 verbatim은 재구성. - let guard_path = strip_verbatim(&std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())); + let guard_path = + strip_verbatim(&std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())); if is_protected(&guard_path) { return Err(SafetyError::Protected(path.to_path_buf())); } @@ -280,7 +295,9 @@ pub fn same_volume(src: &Path, dst: &Path) -> bool { { fn drive(p: &Path) -> Option { p.components().next().and_then(|c| match c { - std::path::Component::Prefix(pr) => Some(pr.as_os_str().to_string_lossy().to_lowercase()), + std::path::Component::Prefix(pr) => { + Some(pr.as_os_str().to_string_lossy().to_lowercase()) + } _ => None, }) } @@ -310,7 +327,10 @@ fn copy_then_hash( ) -> std::io::Result<(u64, u64, Result, Result)> { { let mut src_file = std::fs::File::open(src)?; - let mut dst_file = std::fs::OpenOptions::new().write(true).create_new(true).open(dst)?; + let mut dst_file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(dst)?; std::io::copy(&mut src_file, &mut dst_file)?; // 핸들을 여기서 닫아 이후 metadata/hash_full이 경로로 다시 읽을 때 걸리지 않게 함 } @@ -340,7 +360,10 @@ fn finalize_verified_copy(dst: &Path, verified: bool) -> std::io::Result<()> { Ok(()) } else { let _ = std::fs::remove_file(dst); // 우리가 만든 목적지이므로 정리 - Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "복사 검증 실패")) + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "복사 검증 실패", + )) } } @@ -362,7 +385,10 @@ fn preserve_source_metadata(src: &Path, dst: &Path) -> std::io::Result<()> { if let Ok(accessed) = src_md.accessed() { times = times.set_accessed(accessed); } - std::fs::OpenOptions::new().write(true).open(dst)?.set_times(times)?; + std::fs::OpenOptions::new() + .write(true) + .open(dst)? + .set_times(times)?; // 권한은 **마지막**에 복원한다(원본이 읽기 전용이어도 위 set_times가 이미 끝난 뒤라 안전). // set_permissions는 mtime이 아니라 ctime만 바꾸므로 방금 설정한 mtime을 훼손하지 않는다. std::fs::set_permissions(dst, src_md.permissions())?; @@ -456,7 +482,9 @@ pub fn move_file( ) -> Result<(), SafetyError> { // 보호: src·dst 양쪽, ParentDir 거부, verbatim 정규화 — trash_delete와 동일 리거 for p in [src, dst] { - if p.components().any(|c| matches!(c, std::path::Component::ParentDir)) { + if p.components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { return Err(SafetyError::Protected(p.to_path_buf())); } let guard = normalize_for_guard(p); @@ -466,7 +494,10 @@ pub fn move_file( } // 목적지 충돌 금지 (덮어쓰기 방지) if dst.exists() { - return Err(SafetyError::Trash(format!("목적지가 이미 존재: {}", dst.display()))); + return Err(SafetyError::Trash(format!( + "목적지가 이미 존재: {}", + dst.display() + ))); } // 목적지 부모 디렉토리 생성. 위 protected 검사가 parent 없는 경로를 이미 거부했으므로 // parent는 항상 Some — 폴백(dst 자신)은 실제로 도달 불가지만, 패닉(expect) 대신 한 줄 @@ -523,9 +554,15 @@ mod tests { #[test] fn safety_error_display_messages() { - 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!(SafetyError::Protected(PathBuf::from("/x")) + .to_string() + .contains("보호")); + assert!(SafetyError::Trash("boom".into()) + .to_string() + .contains("휴지통")); + assert!(SafetyError::Journal("boom".into()) + .to_string() + .contains("저널")); } #[test] @@ -537,7 +574,11 @@ mod tests { #[test] fn protects_home_root_but_not_home_children() { // 한 줄: 각 arm이 별도 라인이면 플랫폼별로 반대쪽이 영구 미커버로 남는다 - let home = if cfg!(windows) { std::env::var("USERPROFILE").unwrap() } else { std::env::var("HOME").unwrap() }; + let home = if cfg!(windows) { + std::env::var("USERPROFILE").unwrap() + } else { + std::env::var("HOME").unwrap() + }; assert!(is_protected(Path::new(&home))); assert!(!is_protected(&Path::new(&home).join("some-cache-dir"))); } @@ -554,7 +595,9 @@ mod tests { // 현재 머신의 실제 SystemRoot는 반드시 보호됨 (C:든 다른 드라이브든) let sysroot = std::env::var("SystemRoot").unwrap(); assert!(is_protected(std::path::Path::new(&sysroot))); - assert!(is_protected(&std::path::Path::new(&sysroot).join("System32"))); + assert!(is_protected( + &std::path::Path::new(&sysroot).join("System32") + )); } #[cfg(windows)] @@ -632,7 +675,10 @@ mod tests { let root = if cfg!(windows) { "C:\\Windows" } else { "/usr" }; let err = trash_delete(Path::new(root), 0, &jp, 1); assert!(matches!(err, Err(SafetyError::Protected(_)))); - assert!(journal_recent(&jp, 10).is_empty(), "보호 거부는 저널 이전에 일어나야 함"); + assert!( + journal_recent(&jp, 10).is_empty(), + "보호 거부는 저널 이전에 일어나야 함" + ); } #[test] @@ -668,7 +714,11 @@ mod tests { let items: Vec<_> = trash::os_limited::list() .unwrap() .into_iter() - .filter(|i| i.name.to_string_lossy().contains("disksage-roundtrip-fixture")) + .filter(|i| { + i.name + .to_string_lossy() + .contains("disksage-roundtrip-fixture") + }) .collect(); assert!(!items.is_empty(), "휴지통에 있어야 함"); trash::os_limited::purge_all(items).unwrap(); @@ -706,10 +756,18 @@ mod tests { strip_verbatim(Path::new(r"\\?\UNC\srv\share\dir")), Path::new(r"\\srv\share\dir") ); - assert_eq!(strip_verbatim(Path::new(r"C:\plain")), Path::new(r"C:\plain")); - assert_eq!(strip_verbatim(Path::new("relative/only")), Path::new("relative/only")); + assert_eq!( + strip_verbatim(Path::new(r"C:\plain")), + Path::new(r"C:\plain") + ); + assert_eq!( + strip_verbatim(Path::new("relative/only")), + Path::new("relative/only") + ); // 재구성된 UNC 공유 루트는 parent가 없어 보호된다 (fail-closed 확인) - assert!(is_protected(&strip_verbatim(Path::new(r"\\?\UNC\srv\share")))); + assert!(is_protected(&strip_verbatim(Path::new( + r"\\?\UNC\srv\share" + )))); } #[test] @@ -740,12 +798,26 @@ mod tests { let jp = tmp.path().join("j.jsonl"); let f = tmp.path().join("f.bin"); std::fs::write(&f, b"x").unwrap(); - let protected = std::path::PathBuf::from(if cfg!(windows) { "C:\\Windows\\x" } else { "/usr/x" }); + let protected = std::path::PathBuf::from(if cfg!(windows) { + "C:\\Windows\\x" + } else { + "/usr/x" + }); // 보호된 목적지 - assert!(matches!(move_file(&f, &protected, &jp, 1), Err(SafetyError::Protected(_)))); + assert!(matches!( + move_file(&f, &protected, &jp, 1), + Err(SafetyError::Protected(_)) + )); // 보호된 출발 - let pf = std::path::PathBuf::from(if cfg!(windows) { "C:\\Windows\\y" } else { "/usr/y" }); - assert!(matches!(move_file(&pf, &tmp.path().join("z"), &jp, 1), Err(SafetyError::Protected(_)))); + let pf = std::path::PathBuf::from(if cfg!(windows) { + "C:\\Windows\\y" + } else { + "/usr/y" + }); + assert!(matches!( + move_file(&pf, &tmp.path().join("z"), &jp, 1), + Err(SafetyError::Protected(_)) + )); assert!(journal_recent(&jp, 10).is_empty(), "보호 거부는 저널 이전"); } @@ -766,7 +838,10 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let missing = tmp.path().join("nested").join("does-not-exist.bin"); let expected_base = strip_verbatim(&std::fs::canonicalize(tmp.path()).unwrap()); - assert_eq!(normalize_for_guard(&missing), expected_base.join("nested").join("does-not-exist.bin")); + assert_eq!( + normalize_for_guard(&missing), + expected_base.join("nested").join("does-not-exist.bin") + ); } // Fix 2 회귀 테스트: 슬래시 없는 단일 상대 컴포넌트는 조상이 ""까지 내려가고 canonicalize("")도 @@ -842,8 +917,11 @@ mod tests { assert!(!src.exists(), "원본은 휴지통으로"); assert_eq!(std::fs::read(&dst).unwrap().len(), 40); // 원본이 휴지통에 있음 확인 후 테스트 픽스처만 purge - let items: Vec<_> = trash::os_limited::list().unwrap().into_iter() - .filter(|i| i.name.to_string_lossy().contains("disksage-xvol-fixture")).collect(); + let items: Vec<_> = trash::os_limited::list() + .unwrap() + .into_iter() + .filter(|i| i.name.to_string_lossy().contains("disksage-xvol-fixture")) + .collect(); trash::os_limited::purge_all(items).unwrap(); } @@ -978,7 +1056,10 @@ mod tests { let err = move_file(&src, &dst, &jp, 1); assert!(matches!(err, Err(SafetyError::Trash(_)))); assert!(src.exists(), "부모 생성 실패 시 원본 보존"); - assert!(journal_recent(&jp, 10).is_empty(), "부모 생성 실패는 저널 이전에 실패"); + assert!( + journal_recent(&jp, 10).is_empty(), + "부모 생성 실패는 저널 이전에 실패" + ); } #[test] @@ -1027,7 +1108,10 @@ mod tests { let err = || Err::("read failed".into()); assert!(!hashes_match(&err(), &ok(), 10, 10)); assert!(!hashes_match(&ok(), &err(), 10, 10)); - assert!(!hashes_match(&err(), &err(), 10, 10), "양쪽 다 실패해도 절대 일치로 읽히면 안 됨"); + assert!( + !hashes_match(&err(), &err(), 10, 10), + "양쪽 다 실패해도 절대 일치로 읽히면 안 됨" + ); } #[test] diff --git a/src-tauri/src/scanner.rs b/src-tauri/src/scanner.rs index 6e1f6d285..5c995ed31 100644 --- a/src-tauri/src/scanner.rs +++ b/src-tauri/src/scanner.rs @@ -60,7 +60,10 @@ pub fn scan_dir_with_interval( seen += 1; // 순회/메타데이터 오류는 skipped로 집계 — 한 줄 let-else (오류 분기가 플랫폼별 테스트에만 // 잡히더라도 라인 자체는 항상 실행돼 커버리지가 안정적) - let Ok(e) = entry else { stats.skipped += 1; continue }; + let Ok(e) = entry else { + stats.skipped += 1; + continue; + }; if e.file_type().is_dir() { stats.dirs += 1; // jwalk는 하위 목록 읽기 실패를 Err 항목이 아니라 디렉토리 엔트리의 @@ -70,7 +73,10 @@ pub fn scan_dir_with_interval( } dir_sizes.entry(e.path()).or_insert(0); } else if e.file_type().is_file() { - let Ok(md) = e.metadata() else { stats.skipped += 1; continue }; + let Ok(md) = e.metadata() else { + stats.skipped += 1; + continue; + }; let size = md.len(); stats.files += 1; stats.bytes += size; @@ -278,7 +284,9 @@ mod tests { fn unreadable_dir_counts_as_skipped() { use std::os::unix::fs::PermissionsExt; // root는 권한 비트를 무시하므로 이 테스트는 의미 없음 (한 줄: CI 비-root에서 return 라인 미실행 방지) - if running_as_root() { return; } + if running_as_root() { + return; + } let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let locked = root.join("locked"); @@ -289,7 +297,11 @@ mod tests { let res = scan_dir(root, &AtomicBool::new(false), noop); fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap(); - assert!(res.stats.skipped >= 1, "expected skipped >= 1, got {}", res.stats.skipped); + assert!( + res.stats.skipped >= 1, + "expected skipped >= 1, got {}", + res.stats.skipped + ); assert_eq!(res.stats.files, 0); } @@ -297,7 +309,9 @@ mod tests { #[test] fn metadata_failure_counts_as_skipped() { use std::os::unix::fs::PermissionsExt; - if running_as_root() { return; } + if running_as_root() { + return; + } let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let noexec = root.join("noexec"); @@ -309,7 +323,11 @@ mod tests { let res = scan_dir(root, &AtomicBool::new(false), noop); fs::set_permissions(&noexec, fs::Permissions::from_mode(0o755)).unwrap(); - assert!(res.stats.skipped >= 1, "expected skipped >= 1, got {}", res.stats.skipped); + assert!( + res.stats.skipped >= 1, + "expected skipped >= 1, got {}", + res.stats.skipped + ); assert_eq!(res.stats.bytes, 0); } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 44447af0c..26dd1a63c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -6,7 +6,9 @@ pub struct Settings { } impl Default for Settings { - fn default() -> Self { Settings { online_mode: false } } + fn default() -> Self { + Settings { online_mode: false } + } } /// JSON → Settings. 손상/부분 JSON은 기본값(offline)으로 fail-safe — 설정 파일이 앱을 깨지 않게. diff --git a/src-tauri/src/userrules.rs b/src-tauri/src/userrules.rs index fa4482abc..acf2501a0 100644 --- a/src-tauri/src/userrules.rs +++ b/src-tauri/src/userrules.rs @@ -5,13 +5,20 @@ use std::path::Path; #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub struct RuleMatch { - #[serde(default)] pub ext: Option, - #[serde(default)] pub name_contains: Option, - #[serde(default)] pub path_contains: Option, - #[serde(default)] pub min_size: Option, - #[serde(default)] pub max_size: Option, - #[serde(default)] pub min_age_days: Option, - #[serde(default)] pub max_age_days: Option, + #[serde(default)] + pub ext: Option, + #[serde(default)] + pub name_contains: Option, + #[serde(default)] + pub path_contains: Option, + #[serde(default)] + pub min_size: Option, + #[serde(default)] + pub max_size: Option, + #[serde(default)] + pub min_age_days: Option, + #[serde(default)] + pub max_age_days: Option, } #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] @@ -28,27 +35,55 @@ pub fn parse_rules(json: &str) -> Result, String> { /// 첫 매칭 규칙의 클래스. 매칭 규칙 없으면 None. pub fn classify_by_rules(rules: &[Rule], path: &Path, size: u64, age_days: u64) -> Option { - rules.iter().find(|r| rule_matches(&r.r#match, path, size, age_days)).map(|r| r.class.clone()) + rules + .iter() + .find(|r| rule_matches(&r.r#match, path, size, age_days)) + .map(|r| r.class.clone()) } /// 존재하는 모든 술어가 AND로 일치해야 매칭. 술어 전무(all-None)면 catch-all(true). fn rule_matches(m: &RuleMatch, path: &Path, size: u64, age_days: u64) -> bool { if let Some(ext) = &m.ext { let want = ext.to_lowercase(); - let got = path.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()); - if got.as_deref() != Some(want.as_str()) { return false; } + let got = path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()); + if got.as_deref() != Some(want.as_str()) { + return false; + } } if let Some(sub) = &m.name_contains { let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if !name.contains(sub.as_str()) { return false; } + if !name.contains(sub.as_str()) { + return false; + } } if let Some(sub) = &m.path_contains { - if !path.to_string_lossy().contains(sub.as_str()) { return false; } + if !path.to_string_lossy().contains(sub.as_str()) { + return false; + } + } + if let Some(min) = m.min_size { + if size < min { + return false; + } + } + if let Some(max) = m.max_size { + if size > max { + return false; + } + } + if let Some(min) = m.min_age_days { + if age_days < min { + return false; + } + } + if let Some(max) = m.max_age_days { + if age_days > max { + return false; + } } - if let Some(min) = m.min_size { if size < min { return false; } } - if let Some(max) = m.max_size { if size > max { return false; } } - if let Some(min) = m.min_age_days { if age_days < min { return false; } } - if let Some(max) = m.max_age_days { if age_days > max { return false; } } true } @@ -57,7 +92,17 @@ mod tests { use super::*; use std::path::PathBuf; - fn m() -> RuleMatch { RuleMatch { ext: None, name_contains: None, path_contains: None, min_size: None, max_size: None, min_age_days: None, max_age_days: None } } + fn m() -> RuleMatch { + RuleMatch { + ext: None, + name_contains: None, + path_contains: None, + min_size: None, + max_size: None, + min_age_days: None, + max_age_days: None, + } + } #[test] fn parse_valid_and_malformed() { @@ -72,63 +117,175 @@ mod tests { #[test] fn ext_predicate_case_insensitive() { - let r = vec![Rule { r#match: RuleMatch { ext: Some("ISO".into()), ..m() }, class: "Installer".into() }]; - assert_eq!(classify_by_rules(&r, &PathBuf::from("/d/x.iso"), 0, 0).as_deref(), Some("Installer")); - assert_eq!(classify_by_rules(&r, &PathBuf::from("/d/x.zip"), 0, 0), None); // 확장자 불일치 - assert_eq!(classify_by_rules(&r, &PathBuf::from("/d/noext"), 0, 0), None); // 확장자 없음 + let r = vec![Rule { + r#match: RuleMatch { + ext: Some("ISO".into()), + ..m() + }, + class: "Installer".into(), + }]; + assert_eq!( + classify_by_rules(&r, &PathBuf::from("/d/x.iso"), 0, 0).as_deref(), + Some("Installer") + ); + assert_eq!( + classify_by_rules(&r, &PathBuf::from("/d/x.zip"), 0, 0), + None + ); // 확장자 불일치 + assert_eq!( + classify_by_rules(&r, &PathBuf::from("/d/noext"), 0, 0), + None + ); // 확장자 없음 } #[test] fn name_and_path_contains() { - let rn = vec![Rule { r#match: RuleMatch { name_contains: Some("backup".into()), ..m() }, class: "Archive".into() }]; - assert_eq!(classify_by_rules(&rn, &PathBuf::from("/d/my_backup.tar"), 0, 0).as_deref(), Some("Archive")); - assert_eq!(classify_by_rules(&rn, &PathBuf::from("/d/report.tar"), 0, 0), None); + let rn = vec![Rule { + r#match: RuleMatch { + name_contains: Some("backup".into()), + ..m() + }, + class: "Archive".into(), + }]; + assert_eq!( + classify_by_rules(&rn, &PathBuf::from("/d/my_backup.tar"), 0, 0).as_deref(), + Some("Archive") + ); + assert_eq!( + classify_by_rules(&rn, &PathBuf::from("/d/report.tar"), 0, 0), + None + ); assert_eq!(classify_by_rules(&rn, &PathBuf::from("/"), 0, 0), None); // 파일명 없음 → "" → 불일치 - let rp = vec![Rule { r#match: RuleMatch { path_contains: Some("Downloads".into()), ..m() }, class: "Dl".into() }]; - assert_eq!(classify_by_rules(&rp, &PathBuf::from("/home/Downloads/x.bin"), 0, 0).as_deref(), Some("Dl")); - assert_eq!(classify_by_rules(&rp, &PathBuf::from("/home/Docs/x.bin"), 0, 0), None); + let rp = vec![Rule { + r#match: RuleMatch { + path_contains: Some("Downloads".into()), + ..m() + }, + class: "Dl".into(), + }]; + assert_eq!( + classify_by_rules(&rp, &PathBuf::from("/home/Downloads/x.bin"), 0, 0).as_deref(), + Some("Dl") + ); + assert_eq!( + classify_by_rules(&rp, &PathBuf::from("/home/Docs/x.bin"), 0, 0), + None + ); } #[test] fn size_bounds_inclusive() { - let r = vec![Rule { r#match: RuleMatch { min_size: Some(100), max_size: Some(200), ..m() }, class: "Mid".into() }]; - assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 100, 0).as_deref(), Some("Mid")); // 하한 포함 - assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 200, 0).as_deref(), Some("Mid")); // 상한 포함 - assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 99, 0), None); // 하한 미만 + let r = vec![Rule { + r#match: RuleMatch { + min_size: Some(100), + max_size: Some(200), + ..m() + }, + class: "Mid".into(), + }]; + assert_eq!( + classify_by_rules(&r, &PathBuf::from("/x"), 100, 0).as_deref(), + Some("Mid") + ); // 하한 포함 + assert_eq!( + classify_by_rules(&r, &PathBuf::from("/x"), 200, 0).as_deref(), + Some("Mid") + ); // 상한 포함 + assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 99, 0), None); // 하한 미만 assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 201, 0), None); // 상한 초과 } #[test] fn age_bounds_inclusive() { - let r = vec![Rule { r#match: RuleMatch { min_age_days: Some(30), max_age_days: Some(90), ..m() }, class: "Stale".into() }]; - assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 0, 30).as_deref(), Some("Stale")); // 하한 포함 - assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 0, 90).as_deref(), Some("Stale")); // 상한 포함 + let r = vec![Rule { + r#match: RuleMatch { + min_age_days: Some(30), + max_age_days: Some(90), + ..m() + }, + class: "Stale".into(), + }]; + assert_eq!( + classify_by_rules(&r, &PathBuf::from("/x"), 0, 30).as_deref(), + Some("Stale") + ); // 하한 포함 + assert_eq!( + classify_by_rules(&r, &PathBuf::from("/x"), 0, 90).as_deref(), + Some("Stale") + ); // 상한 포함 assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 0, 29), None); // 하한 미만 assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 0, 91), None); // 상한 초과 } #[test] fn age_ands_with_other_predicates() { - let r = vec![Rule { r#match: RuleMatch { ext: Some("iso".into()), min_age_days: Some(365), ..m() }, class: "OldIso".into() }]; - assert_eq!(classify_by_rules(&r, &PathBuf::from("/x.iso"), 0, 400).as_deref(), Some("OldIso")); - assert_eq!(classify_by_rules(&r, &PathBuf::from("/x.iso"), 0, 100), None); // ext OK, age 미달 → AND 실패 + let r = vec![Rule { + r#match: RuleMatch { + ext: Some("iso".into()), + min_age_days: Some(365), + ..m() + }, + class: "OldIso".into(), + }]; + assert_eq!( + classify_by_rules(&r, &PathBuf::from("/x.iso"), 0, 400).as_deref(), + Some("OldIso") + ); + assert_eq!( + classify_by_rules(&r, &PathBuf::from("/x.iso"), 0, 100), + None + ); // ext OK, age 미달 → AND 실패 } #[test] fn and_semantics_and_first_match_wins_and_catch_all() { // AND: ext+min_size 둘 다 만족해야 - let r = vec![Rule { r#match: RuleMatch { ext: Some("mp4".into()), min_size: Some(1000), ..m() }, class: "BigVid".into() }]; - assert_eq!(classify_by_rules(&r, &PathBuf::from("/x.mp4"), 2000, 0).as_deref(), Some("BigVid")); - assert_eq!(classify_by_rules(&r, &PathBuf::from("/x.mp4"), 500, 0), None); // ext OK, size 미달 → AND 실패 - // 첫 매칭 승리 + let r = vec![Rule { + r#match: RuleMatch { + ext: Some("mp4".into()), + min_size: Some(1000), + ..m() + }, + class: "BigVid".into(), + }]; + assert_eq!( + classify_by_rules(&r, &PathBuf::from("/x.mp4"), 2000, 0).as_deref(), + Some("BigVid") + ); + assert_eq!( + classify_by_rules(&r, &PathBuf::from("/x.mp4"), 500, 0), + None + ); // ext OK, size 미달 → AND 실패 + // 첫 매칭 승리 let ord = vec![ - Rule { r#match: RuleMatch { ext: Some("log".into()), ..m() }, class: "First".into() }, - Rule { r#match: RuleMatch { ext: Some("log".into()), ..m() }, class: "Second".into() }, + Rule { + r#match: RuleMatch { + ext: Some("log".into()), + ..m() + }, + class: "First".into(), + }, + Rule { + r#match: RuleMatch { + ext: Some("log".into()), + ..m() + }, + class: "Second".into(), + }, ]; - assert_eq!(classify_by_rules(&ord, &PathBuf::from("/x.log"), 0, 0).as_deref(), Some("First")); + assert_eq!( + classify_by_rules(&ord, &PathBuf::from("/x.log"), 0, 0).as_deref(), + Some("First") + ); // all-None catch-all - let catch = vec![Rule { r#match: m(), class: "Any".into() }]; - assert_eq!(classify_by_rules(&catch, &PathBuf::from("/anything.zzz"), 0, 0).as_deref(), Some("Any")); + let catch = vec![Rule { + r#match: m(), + class: "Any".into(), + }]; + assert_eq!( + classify_by_rules(&catch, &PathBuf::from("/anything.zzz"), 0, 0).as_deref(), + Some("Any") + ); // 빈 규칙 → None assert_eq!(classify_by_rules(&[], &PathBuf::from("/x"), 0, 0), None); } diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index 468cfcc4c..6974bb2d6 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -21,7 +21,11 @@ pub fn ddg_query(ext: &str) -> String { pub fn parse_ddg_abstract(json: &str) -> Option { let v = serde_json::from_str::(json).ok()?; let s = v.get("AbstractText")?.as_str()?; - if s.is_empty() { None } else { Some(s.to_string()) } + if s.is_empty() { + None + } else { + Some(s.to_string()) + } } #[cfg(test)] @@ -37,11 +41,13 @@ mod tests { } #[test] fn abstract_extracted_or_none() { - assert_eq!(parse_ddg_abstract(r#"{"AbstractText":"Autodesk FBX is a 3D format."}"#), - Some("Autodesk FBX is a 3D format.".to_string())); + assert_eq!( + parse_ddg_abstract(r#"{"AbstractText":"Autodesk FBX is a 3D format."}"#), + Some("Autodesk FBX is a 3D format.".to_string()) + ); assert_eq!(parse_ddg_abstract(r#"{"AbstractText":""}"#), None); // 빈 abstract - assert_eq!(parse_ddg_abstract(r#"{"Heading":"x"}"#), None); // 필드 없음 - assert_eq!(parse_ddg_abstract("not json"), None); // 파싱 실패 + assert_eq!(parse_ddg_abstract(r#"{"Heading":"x"}"#), None); // 필드 없음 + assert_eq!(parse_ddg_abstract("not json"), None); // 파싱 실패 assert_eq!(parse_ddg_abstract(r#"{"AbstractText":5}"#), None); // 문자열 아님 } } diff --git a/src-tauri/tests/cloud_transfer_coverage_contract.rs b/src-tauri/tests/cloud_transfer_coverage_contract.rs index 245a5d299..f75223ada 100644 --- a/src-tauri/tests/cloud_transfer_coverage_contract.rs +++ b/src-tauri/tests/cloud_transfer_coverage_contract.rs @@ -68,7 +68,10 @@ fn cloud_plan_exports_backend_authored_approval_phrase() { "pub copy_approval_max_age_ms: u64", "cloud_copy_approval_phrase(&candidate, action)", ] { - assert!(view_source.contains(marker), "missing backend plan-view marker: {marker}"); + assert!( + view_source.contains(marker), + "missing backend plan-view marker: {marker}" + ); } assert!( command_source.contains("Result"), @@ -80,7 +83,10 @@ fn cloud_plan_exports_backend_authored_approval_phrase() { "copy_approval_max_age_ms?: number", "/** Returns the exact backend-authored phrase only for the matching candidate action. */", ] { - assert!(api_source.contains(marker), "missing frontend plan contract: {marker}"); + assert!( + api_source.contains(marker), + "missing frontend plan contract: {marker}" + ); } assert!( !api_source.contains("`DiskSage cloud ${action} ${candidate.review_fingerprint} 승인`"), diff --git a/src-tauri/tests/icloud_local_eviction_batch_documentation_test.rs b/src-tauri/tests/icloud_local_eviction_batch_documentation_test.rs index 84a698ee2..988145e9d 100644 --- a/src-tauri/tests/icloud_local_eviction_batch_documentation_test.rs +++ b/src-tauri/tests/icloud_local_eviction_batch_documentation_test.rs @@ -4,8 +4,7 @@ //! references reviewable alongside the Rust behavior they describe. /// Operator guide compiled into the test binary so documentation claims are checked offline. -const OPERATOR_GUIDE: &str = - include_str!("../../docs/development/icloud-local-eviction-batch.md"); +const OPERATOR_GUIDE: &str = include_str!("../../docs/development/icloud-local-eviction-batch.md"); /// Verifies that the operator guide maps fail-closed behavior to authoritative controls. #[test] From 2fb9a8bab8f061f03224fcd26f04b1cd55b6112c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:25:38 +0900 Subject: [PATCH 22/66] ci: remove completed PR 133 rustfmt repair workflow --- .github/workflows/pr133-rustfmt-diff.yml | 50 ------------------------ 1 file changed, 50 deletions(-) delete mode 100644 .github/workflows/pr133-rustfmt-diff.yml diff --git a/.github/workflows/pr133-rustfmt-diff.yml b/.github/workflows/pr133-rustfmt-diff.yml deleted file mode 100644 index 04bebd830..000000000 --- a/.github/workflows/pr133-rustfmt-diff.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: PR133 Rustfmt Repair - -on: - push: - branches: [feat/podman-desktop-evidence] - paths: - - ".github/workflows/pr133-rustfmt-diff.yml" - -permissions: - contents: write - -concurrency: - group: pr133-rustfmt-repair-${{ github.ref }} - cancel-in-progress: false - -jobs: - rustfmt-repair: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: true - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - with: - components: rustfmt - - name: Apply exact rustfmt output - run: cargo fmt --manifest-path src-tauri/Cargo.toml - - name: Verify bounded formatting-only changes - run: | - set -euo pipefail - git diff --check - mapfile -t changed_files < <(git diff --name-only) - test "${#changed_files[@]}" -gt 0 - for changed_file in "${changed_files[@]}"; do - case "$changed_file" in - src-tauri/*.rs|src-tauri/src/*.rs|src-tauri/src/**/*.rs) ;; - *) - echo "Unexpected rustfmt output outside Rust sources: $changed_file" >&2 - exit 1 - ;; - esac - done - - name: Commit exact rustfmt output - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -- src-tauri - git commit -m "style: apply repository-wide rustfmt output" - git push origin "HEAD:${GITHUB_REF_NAME}" From cbf2672277385a28608fe44f4d831a21595c238d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:29:10 +0900 Subject: [PATCH 23/66] test: require JSDoc for Podman evidence functions --- src/lib/podmanEvidence.docstrings.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/lib/podmanEvidence.docstrings.test.ts diff --git a/src/lib/podmanEvidence.docstrings.test.ts b/src/lib/podmanEvidence.docstrings.test.ts new file mode 100644 index 000000000..c2bc1d2c7 --- /dev/null +++ b/src/lib/podmanEvidence.docstrings.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync(new URL("./podmanEvidence.ts", import.meta.url), "utf8"); +const productionFunctions = [ + ...source.matchAll(/^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z0-9_]+)/gm), +].map((match) => match[1]); + +describe("Podman evidence documentation contract", () => { + it("keeps every production function beginner-readable with an adjacent JSDoc", () => { + expect(productionFunctions.length).toBeGreaterThan(0); + + for (const functionName of productionFunctions) { + const documentedFunction = new RegExp( + String.raw`/\*\*[\s\S]*?\*/\s*(?:export\s+)?(?:async\s+)?function\s+${functionName}\b`, + ); + expect(source, `missing adjacent JSDoc for ${functionName}`).toMatch(documentedFunction); + } + }); +}); From 8bdb84ac83a4a8f7b42168c9148124390c951e6d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:30:07 +0900 Subject: [PATCH 24/66] docs: complete Podman evidence function JSDoc --- src/lib/podmanEvidence.ts | 79 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/lib/podmanEvidence.ts b/src/lib/podmanEvidence.ts index f3bc75ab4..216a6a70e 100644 --- a/src/lib/podmanEvidence.ts +++ b/src/lib/podmanEvidence.ts @@ -68,6 +68,14 @@ export interface PodmanEvidenceView { type InvokeFunction = (command: string) => Promise; type JsonRecord = Record; +/** + * Require a plain JSON object and reject arrays, null, and primitive values. + * + * @param value - Untrusted value received from the Tauri boundary. + * @param label - Stable field label included in the fail-closed error code. + * @returns The same value narrowed to a string-keyed JSON record. + * @throws When the value is not a plain object-shaped record. + */ function record(value: unknown, label: string): JsonRecord { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error(`invalid-${label}`); @@ -75,16 +83,43 @@ function record(value: unknown, label: string): JsonRecord { return value as JsonRecord; } +/** + * Require a string value from an untrusted response field. + * + * @param value - Candidate field value. + * @param label - Stable field label included in the error code. + * @returns The validated string. + * @throws When the value is not a string. + */ function stringValue(value: unknown, label: string): string { if (typeof value !== "string") throw new Error(`invalid-${label}`); return value; } +/** + * Require a boolean value from an untrusted response field. + * + * @param value - Candidate field value. + * @param label - Stable field label included in the error code. + * @returns The validated boolean. + * @throws When the value is not a boolean. + */ function booleanValue(value: unknown, label: string): boolean { if (typeof value !== "boolean") throw new Error(`invalid-${label}`); return value; } +/** + * Require a non-negative JavaScript safe integer. + * + * Byte counts and record counts are rejected rather than rounded when Rust-to-JavaScript + * serialization produces an unsafe, negative, fractional, or nonnumeric value. + * + * @param value - Candidate numeric field value. + * @param label - Stable field label included in the error code. + * @returns The validated unsigned safe integer. + * @throws When the value cannot be represented exactly and safely in JavaScript. + */ function unsignedInteger(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { throw new Error(`invalid-${label}`); @@ -92,10 +127,26 @@ function unsignedInteger(value: unknown, label: string): number { return value; } +/** + * Preserve an explicitly unavailable observation as null or validate its unsigned value. + * + * @param value - Candidate field value, where null means the probe could not observe it. + * @param label - Stable field label included in the error code. + * @returns Null for an unavailable observation, otherwise a validated unsigned safe integer. + * @throws When a non-null value is not a safe unsigned integer. + */ function optionalUnsignedInteger(value: unknown, label: string): number | null { return value === null ? null : unsignedInteger(value, label); } +/** + * Require an array containing only strings and return a defensive copy. + * + * @param value - Candidate list value. + * @param label - Stable field label included in the error code. + * @returns A new array containing the validated strings. + * @throws When the value is not a string-only array. + */ function stringArray(value: unknown, label: string): string[] { if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) { throw new Error(`invalid-${label}`); @@ -103,6 +154,13 @@ function stringArray(value: unknown, label: string): string[] { return [...value]; } +/** + * Validate an optional lowercase SHA-256 commitment. + * + * @param value - Null when no candidate set was observed, otherwise the encoded digest. + * @returns Null or a 64-character lowercase hexadecimal SHA-256 string. + * @throws When a supplied fingerprint is malformed or uses a different encoding. + */ function sha256OrNull(value: unknown): string | null { if (value === null) return null; const fingerprint = stringValue(value, "image-candidate-set-sha256"); @@ -112,6 +170,13 @@ function sha256OrNull(value: unknown): string | null { return fingerprint; } +/** + * Parse the capacity section while preserving every measurement as a distinct concept. + * + * @param value - Untrusted capacity object from the Rust response. + * @returns Validated capacity observations with unavailable values preserved as null. + * @throws When the section or any member violates the versioned desktop contract. + */ function parseCapacity(value: unknown): PodmanDesktopCapacityEvidence { const capacity = record(value, "podman-capacity"); return { @@ -141,6 +206,13 @@ function parseCapacity(value: unknown): PodmanDesktopCapacityEvidence { }; } +/** + * Parse logical cleanup candidates without treating them as verified physical savings. + * + * @param value - Untrusted candidate object from the Rust response. + * @returns Validated candidate counts, byte observations, and optional set commitment. + * @throws When a candidate field violates its type, range, or fingerprint contract. + */ function parseCandidates(value: unknown): PodmanDesktopCandidateEvidence { const candidates = record(value, "podman-candidates"); return { @@ -168,6 +240,13 @@ function parseCandidates(value: unknown): PodmanDesktopCandidateEvidence { }; } +/** + * Parse independent review requirements for images, stopped containers, and volumes. + * + * @param value - Untrusted review-boundary object from the Rust response. + * @returns Three validated booleans that remain advisory and mutually non-authorizing. + * @throws When any review boundary is absent or not boolean. + */ function parseReviewBoundaries(value: unknown): PodmanDesktopReviewBoundaries { const boundaries = record(value, "podman-review-boundaries"); return { From 06b2ad5f4d4502f391c4ab1322357c2915788726 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:30:46 +0900 Subject: [PATCH 25/66] docs: record Podman JSDoc regression contract --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb9ae9d93..72322d12f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Add a read-only Podman evidence panel to Cleanup that separately displays configured VM capacity, raw-image logical size, host allocation, guest filesystem observations, Podman store observations, image/stopped-container/volume logical candidates, evidence completeness, stable issue codes, and a redacted candidate-set fingerprint. - Add a privacy-safe Tauri contract that removes machine names, local paths, graph-root locations, image identifiers, tags, command output, and dynamic error details before evidence reaches the desktop frontend. +- Add beginner-readable JSDoc for every Podman frontend contract function and a deterministic source-level regression test that fails when any production function loses its adjacent documentation. ### Changed From 950339e9f91975a206cd683cfd5e39ab1a45f05f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:36:53 +0900 Subject: [PATCH 26/66] test: require privacy-safe Podman UI errors --- src/lib/podmanEvidence.error.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/lib/podmanEvidence.error.test.ts diff --git a/src/lib/podmanEvidence.error.test.ts b/src/lib/podmanEvidence.error.test.ts new file mode 100644 index 000000000..fe3378060 --- /dev/null +++ b/src/lib/podmanEvidence.error.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { podmanEvidenceErrorMessage } from "./podmanEvidence"; + +describe("podmanEvidenceErrorMessage", () => { + it.each([ + new Error("podman failed at /Users/alice/.local/share/containers"), + "transport error: private-machine.sock", + { secret: "account-local-context" }, + null, + undefined, + ])("returns one stable privacy-safe message for untrusted failure detail %#", (reason) => { + const message = podmanEvidenceErrorMessage(reason); + expect(message).toBe("podman-evidence-unavailable"); + expect(message).not.toContain("alice"); + expect(message).not.toContain("private-machine"); + expect(message).not.toContain("account-local-context"); + }); +}); From 62ff74308634c37466047e320f7f40998181174f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:37:26 +0900 Subject: [PATCH 27/66] fix: redact Podman desktop error details --- src/lib/podmanEvidenceError.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/lib/podmanEvidenceError.ts diff --git a/src/lib/podmanEvidenceError.ts b/src/lib/podmanEvidenceError.ts new file mode 100644 index 000000000..ffddb6a26 --- /dev/null +++ b/src/lib/podmanEvidenceError.ts @@ -0,0 +1,14 @@ +/** + * Convert any untrusted Podman inspection failure into one stable privacy-safe code. + * + * Tauri transport failures, operating-system errors, and thrown JavaScript values may contain + * machine names, account-local paths, socket locations, or command details. The desktop UI must + * not render those values. Detailed diagnosis remains local to trusted logs and is never copied + * into the shareable evidence surface. + * + * @param reason - Untrusted failure detail intentionally discarded at the UI boundary. + * @returns A stable non-sensitive code suitable for user-facing status text and telemetry. + */ +export function podmanEvidenceErrorMessage(_reason: unknown): string { + return "podman-evidence-unavailable"; +} From 9b8af4f0bfbf65c9d14e626e66afa1933033bcf0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:37:38 +0900 Subject: [PATCH 28/66] test: bind Podman error redaction contract --- src/lib/podmanEvidence.error.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/podmanEvidence.error.test.ts b/src/lib/podmanEvidence.error.test.ts index fe3378060..3c3172090 100644 --- a/src/lib/podmanEvidence.error.test.ts +++ b/src/lib/podmanEvidence.error.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { podmanEvidenceErrorMessage } from "./podmanEvidence"; +import { podmanEvidenceErrorMessage } from "./podmanEvidenceError"; describe("podmanEvidenceErrorMessage", () => { it.each([ From f562e48cb83f1c2b97d775ec046fe9c41f29338c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:38:18 +0900 Subject: [PATCH 29/66] fix: redact untrusted Podman UI failures --- src/lib/PodmanEvidence.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/PodmanEvidence.svelte b/src/lib/PodmanEvidence.svelte index ba0696456..96c344c7c 100644 --- a/src/lib/PodmanEvidence.svelte +++ b/src/lib/PodmanEvidence.svelte @@ -6,6 +6,7 @@ type OptionalBytes, type PodmanDesktopEvidence, } from "./podmanEvidence"; + import { podmanEvidenceErrorMessage } from "./podmanEvidenceError"; let evidence: PodmanDesktopEvidence | null = $state(null); let busy = $state(false); @@ -27,7 +28,7 @@ evidence = await loadPodmanEvidence(); } catch (reason) { evidence = null; - error = String(reason); + error = podmanEvidenceErrorMessage(reason); } finally { busy = false; } From d39ee608e4d6d0521c60ed67dc1b95701c3d5423 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:38:28 +0900 Subject: [PATCH 30/66] test: cover Podman error redaction helper --- vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vitest.config.ts b/vitest.config.ts index ce8ad23f0..1b1ea7288 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ "src/lib/dupeGuard.ts", "src/lib/verdictBadge.ts", "src/lib/podmanEvidence.ts", + "src/lib/podmanEvidenceError.ts", ], reporter: ["text", "json", "json-summary"], // ponytail: 위 include의 헤드리스 순수 로직/API 계약 파일은 완전 검증 가능하므로 From d6e6632456e299245afa1d0dae59bd8e09c7832d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:39:04 +0900 Subject: [PATCH 31/66] docs: define Podman UI error privacy boundary --- docs/architecture/podman-desktop-evidence.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/architecture/podman-desktop-evidence.md b/docs/architecture/podman-desktop-evidence.md index 949d5ce3e..739324b1b 100644 --- a/docs/architecture/podman-desktop-evidence.md +++ b/docs/architecture/podman-desktop-evidence.md @@ -12,7 +12,7 @@ DiskSage already has a Rust-first, read-only Podman evidence probe that distingu The UI must not turn evidence into authority. Podman documents that image reclaimable values can overstate what a prune would actually free when layers are shared. DiskSage therefore treats all `podman system df` candidate values as logical review evidence rather than verified host physical reclaimability. -The headless report also contains local-only details such as machine names, configuration paths, raw-image paths, graph-root paths, and dynamic command errors. Those details are useful for local diagnosis but are unnecessary for the desktop summary and unsafe for telemetry or shareable evidence. +The headless report also contains local-only details such as machine names, configuration paths, raw-image paths, graph-root paths, and dynamic command errors. Those details are useful for local diagnosis but are unnecessary for the desktop summary and unsafe for telemetry or shareable evidence. Tauri transport failures and arbitrary JavaScript rejection values can also contain account-local paths, socket names, or command detail, so the UI error boundary must redact them independently of the Rust projection. ## Decision @@ -55,12 +55,14 @@ The desktop surface exposes no prune, remove, machine start/stop, VM deletion, T Images, stopped containers, and local volumes have separate review booleans and separate UI sections. A review signal for one domain never authorizes another domain. This preserves future compatibility with distinct approval records and least-privilege workflows. -### 4. Keep visual semantics explicit and accessible +### 4. Keep visual semantics explicit, accessible, and privacy-safe The panel uses semantic headings, definition lists, buttons, `role="status"` for progress and results, and `role="alert"` for errors. WCAG 2.2 requires status messages to be programmatically determinable without moving focus; the component uses live status regions for that purpose. The UI never uses color as the only carrier of completeness. Text labels always state “증거 완전” or “부분 증거.” +The UI never renders `String(reason)` or another untrusted exception representation. `podmanEvidenceErrorMessage` discards every transport, operating-system, and JavaScript failure detail and returns only `podman-evidence-unavailable`. Detailed diagnosis remains confined to trusted local logs and does not cross into the desktop evidence, telemetry, or shareable-evidence boundary. + ### 5. Preserve standalone and MSA compatibility The desktop response is a versioned JSON contract with no dependency on Naruon or another CWL service. DiskSage runs independently. A future Naruon or fleet-management adapter may consume the same privacy-safe schema without receiving local paths or identifiers. @@ -72,13 +74,15 @@ The desktop response is a versioned JSON contract with no dependency on Naruon o - Buyers can inspect a concrete Podman storage gap from the main Cleanup workflow. - Logical size, host allocation, guest use, and verified physical reclaimability cannot be silently conflated. - Local identifiers stay outside the frontend contract, telemetry, and shareable evidence boundary. +- Transport and JavaScript failures cannot leak machine names, paths, sockets, or command detail through the visible error region. - The architecture can later add separate governed image, container, and volume approval records without changing the read-only evidence contract. -- Headless API validation and view-state tests remain deterministic and are included in the 100% frontend statement, branch, function, and line coverage gate. +- Headless API validation, error-redaction tests, and view-state tests remain deterministic and are included in the 100% frontend statement, branch, function, and line coverage gate. ### Negative - The UI intentionally cannot perform cleanup. Operators must use a separate reviewed workflow until a mutation design includes exact candidate binding, independent approval, rollback evidence, and before-and-after host verification. - Some evidence remains unavailable when Podman is absent, the machine is stopped, or the API is unhealthy. Unknown values remain `null`; the UI never converts missing evidence to zero. +- Visible failures intentionally use a stable generic code; sensitive operational detail must be inspected through trusted local diagnostics rather than the shareable desktop surface. ## Verification matrix @@ -90,9 +94,11 @@ The desktop response is a versioned JSON contract with no dependency on Naruon o | Missing observations stay unknown | Rust and TypeScript null-preservation tests | | Exact Tauri command contract | Mocked TypeScript invoke test | | Schema/type/range drift rejected | TypeScript parser tests | +| Untrusted failure details never reach visible UI | `podmanEvidence.error.test.ts` supplies path, socket, object, null, and undefined failures and expects one stable code | | Progress and errors announced | Svelte markup uses `role="status"` and `role="alert"` | | No mutation surface | Registered command list exposes inspection only | -| Frontend logic coverage | `vitest.config.ts` includes `podmanEvidence.ts` at 100% thresholds | +| Frontend logic coverage | `vitest.config.ts` includes `podmanEvidence.ts` and `podmanEvidenceError.ts` at 100% thresholds | +| Beginner-readable function documentation | Source-level JSDoc regression test checks every production function declaration | ## Release acceptance From b62b9e654fc0c693e80f944210795af59ee8bb5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:39:20 +0900 Subject: [PATCH 32/66] docs: record Podman UI error redaction --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72322d12f..ecdf88302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,3 +33,4 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Generate the npm lockfile in an exact-head validation job with repository contents read-only and dependency lifecycle scripts disabled, bind the artifact to SHA-256 evidence, and grant `contents: write` only to a separate publication job that verifies the same-run artifact and unchanged branch head before committing the lockfile. - Removed obsolete one-shot repair workflows and patch scripts so repository automation no longer retains dormant write-capable recovery paths. - Keep the Podman desktop surface observation-only: it exposes no prune, remove, machine stop/start, VM deletion, TRIM, raw-image mutation, or shell-string construction path, and it never labels Podman logical candidates as verified host physical reclaimability. +- Replace untrusted Tauri, operating-system, and JavaScript failure details with one stable Podman UI error code so account-local paths, machine names, socket locations, and command details cannot leak through the visible desktop evidence boundary. From c5b2df3d5dc48a69d5539192e897ae0f7a4f36f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:41:20 +0900 Subject: [PATCH 33/66] test: reject delimiter-free Podman issue detail --- .../tests/podman_desktop_issue_privacy.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src-tauri/tests/podman_desktop_issue_privacy.rs diff --git a/src-tauri/tests/podman_desktop_issue_privacy.rs b/src-tauri/tests/podman_desktop_issue_privacy.rs new file mode 100644 index 000000000..e4591fd98 --- /dev/null +++ b/src-tauri/tests/podman_desktop_issue_privacy.rs @@ -0,0 +1,49 @@ +//! Integration regression for privacy-safe Podman issue codes. +//! +//! Headless probe failures are untrusted local diagnostic strings. A missing delimiter must never +//! allow a path, socket, machine name, or command detail to cross the desktop IPC boundary. + +use disksage_lib::podman_desktop::redact_podman_reclaim_plan; +use disksage_lib::podman_reclaim::{ + PodmanReclaimAssessment, PodmanReclaimPlan, PODMAN_RECLAIM_SCHEMA_KIND, +}; + +/// Builds the smallest complete public plan needed to exercise issue-code projection. +fn plan_with_issue(issue: &str) -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: false, + elapsed_ms: 1, + machine: None, + raw_image: None, + guest_filesystem: None, + store: None, + system_df: None, + unused_images: None, + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: None, + podman_reported_reclaimable_bytes: None, + raw_allocated_minus_guest_used_bytes: None, + status: "unverified".to_string(), + reason_codes: vec![], + recommended_actions: vec![], + }, + issues: vec![issue.to_string()], + } +} + +/// Rejects delimiter-free local paths instead of serializing them as desktop issue codes. +#[test] +fn delimiter_free_private_issue_detail_falls_back_to_stable_code() { + let evidence = redact_podman_reclaim_plan(plan_with_issue( + "/Users/alice/.local/share/containers/private-machine.sock", + )); + + assert_eq!(evidence.issue_codes, vec!["podman-evidence-error"]); + let json = serde_json::to_string(&evidence).expect("desktop evidence must serialize"); + assert!(!json.contains("alice")); + assert!(!json.contains("private-machine")); + assert!(!json.contains("/Users/")); +} From f616bffdea535f0ad5d4b3f04c470e0228e953b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:42:57 +0900 Subject: [PATCH 34/66] fix: constrain Podman desktop issue codes --- src-tauri/src/podman_desktop.rs | 36 +++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/podman_desktop.rs b/src-tauri/src/podman_desktop.rs index c8a14e181..e62746b91 100644 --- a/src-tauri/src/podman_desktop.rs +++ b/src-tauri/src/podman_desktop.rs @@ -102,6 +102,7 @@ pub struct PodmanDesktopEvidence { pub notices: Vec, } +/// Return true only for a canonical lowercase hexadecimal SHA-256 encoding. fn valid_sha256(value: &str) -> bool { value.len() == 64 && value @@ -109,15 +110,32 @@ fn valid_sha256(value: &str) -> bool { .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) } +/// Reduce untrusted local diagnostic text to a bounded kebab-case issue code. +/// +/// The prefix before the first colon is accepted only when it starts with a lowercase ASCII +/// letter, contains lowercase ASCII letters, digits, or hyphens, and is at most 96 bytes. Paths, +/// socket names, whitespace, uppercase text, Unicode, underscores, and empty prefixes fall back to +/// one stable generic code rather than crossing the desktop IPC boundary. fn stable_issue_code(value: &str) -> String { - value - .split(':') - .next() - .filter(|code| !code.is_empty()) - .unwrap_or("podman-evidence-error") - .to_string() + let code = value.split(':').next().unwrap_or_default(); + let valid = !code.is_empty() + && code.len() <= 96 + && code + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && code.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' + }); + + if valid { + code.to_string() + } else { + "podman-evidence-error".to_string() + } } +/// Return whether a matching recommended action requires independent human approval. fn has_action(plan: &PodmanReclaimPlan, kind: PodmanRecommendedActionKind) -> bool { plan.assessment .recommended_actions @@ -450,6 +468,12 @@ mod tests { fn issue_code_fallback_and_fingerprint_validation_are_stable() { assert_eq!(stable_issue_code(""), "podman-evidence-error"); assert_eq!(stable_issue_code(":private"), "podman-evidence-error"); + assert_eq!( + stable_issue_code("/Users/alice/private-machine.sock"), + "podman-evidence-error" + ); + assert_eq!(stable_issue_code("UPPERCASE"), "podman-evidence-error"); + assert_eq!(stable_issue_code("unsafe_code"), "podman-evidence-error"); assert_eq!(stable_issue_code("stable:private"), "stable"); assert!(valid_sha256(&"0".repeat(64))); assert!(!valid_sha256(&"A".repeat(64))); From d68cfc9a33821f3247a5e175b709c30aaa3ab697 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:43:22 +0900 Subject: [PATCH 35/66] docs: record strict Podman issue-code redaction --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecdf88302..df379621d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,3 +34,4 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Removed obsolete one-shot repair workflows and patch scripts so repository automation no longer retains dormant write-capable recovery paths. - Keep the Podman desktop surface observation-only: it exposes no prune, remove, machine stop/start, VM deletion, TRIM, raw-image mutation, or shell-string construction path, and it never labels Podman logical candidates as verified host physical reclaimability. - Replace untrusted Tauri, operating-system, and JavaScript failure details with one stable Podman UI error code so account-local paths, machine names, socket locations, and command details cannot leak through the visible desktop evidence boundary. +- Accept Podman probe issue prefixes only as bounded lowercase kebab-case codes; delimiter-free paths, sockets, uppercase text, Unicode, whitespace, underscores, and malformed prefixes collapse to `podman-evidence-error` before desktop IPC serialization. From ad173959525014c120062768fbfd041d31e91ae4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:43:52 +0900 Subject: [PATCH 36/66] docs: define strict Podman issue-code admission --- docs/architecture/podman-desktop-evidence.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/architecture/podman-desktop-evidence.md b/docs/architecture/podman-desktop-evidence.md index 739324b1b..beb9e6364 100644 --- a/docs/architecture/podman-desktop-evidence.md +++ b/docs/architecture/podman-desktop-evidence.md @@ -43,7 +43,7 @@ The projection excludes: - command output and dynamic error details; - any mutation command or approval record. -Issue strings are reduced to the stable code before the first colon. Invalid candidate fingerprints fail closed: the fingerprint is removed, the evidence is marked incomplete, and a stable issue code is added. +Issue strings are reduced to the prefix before the first colon only when that prefix is a bounded lowercase kebab-case code: it must start with a lowercase ASCII letter, contain only lowercase ASCII letters, digits, or hyphens, and be no longer than 96 bytes. Delimiter-free paths, sockets, whitespace, uppercase text, Unicode, underscores, empty prefixes, and malformed values collapse to `podman-evidence-error`. Invalid candidate fingerprints fail closed: the fingerprint is removed, the evidence is marked incomplete, and a stable issue code is added. ### 2. Keep the Tauri command read-only and argv-based @@ -74,9 +74,10 @@ The desktop response is a versioned JSON contract with no dependency on Naruon o - Buyers can inspect a concrete Podman storage gap from the main Cleanup workflow. - Logical size, host allocation, guest use, and verified physical reclaimability cannot be silently conflated. - Local identifiers stay outside the frontend contract, telemetry, and shareable evidence boundary. +- Malformed or delimiter-free probe issues cannot masquerade as safe codes or serialize local path content. - Transport and JavaScript failures cannot leak machine names, paths, sockets, or command detail through the visible error region. - The architecture can later add separate governed image, container, and volume approval records without changing the read-only evidence contract. -- Headless API validation, error-redaction tests, and view-state tests remain deterministic and are included in the 100% frontend statement, branch, function, and line coverage gate. +- Headless API validation, issue-code privacy tests, error-redaction tests, and view-state tests remain deterministic and are included in the 100% frontend statement, branch, function, and line coverage gate. ### Negative @@ -89,6 +90,7 @@ The desktop response is a versioned JSON contract with no dependency on Naruon o | Invariant | Deterministic evidence | |---|---| | No machine names or paths in desktop JSON | Rust serialization test searches for private fixture values | +| Delimiter-free or malformed issue text cannot cross IPC | Rust unit and integration tests expect `podman-evidence-error` for paths, uppercase text, and underscores | | Image/container/volume review separation | Rust projection test and TypeScript view-model test | | Invalid fingerprint fails closed | Rust and TypeScript malformed-fingerprint tests | | Missing observations stay unknown | Rust and TypeScript null-preservation tests | From 8ef903c11f2e9fea3a001421ade3c85df8b0d597 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 01:47:27 +0900 Subject: [PATCH 37/66] style: apply rustfmt to Podman issue-code validation --- src-tauri/src/podman_desktop.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/podman_desktop.rs b/src-tauri/src/podman_desktop.rs index e62746b91..b5fac98f4 100644 --- a/src-tauri/src/podman_desktop.rs +++ b/src-tauri/src/podman_desktop.rs @@ -124,9 +124,9 @@ fn stable_issue_code(value: &str) -> String { .bytes() .next() .is_some_and(|byte| byte.is_ascii_lowercase()) - && code.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' - }); + && code + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); if valid { code.to_string() From 8bed5c2af75879a86d585753a347b85e24600cd7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 02:05:22 +0900 Subject: [PATCH 38/66] test: require Podman Rust function documentation --- .../podman_desktop_documentation_contract.rs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src-tauri/tests/podman_desktop_documentation_contract.rs diff --git a/src-tauri/tests/podman_desktop_documentation_contract.rs b/src-tauri/tests/podman_desktop_documentation_contract.rs new file mode 100644 index 000000000..bf254e575 --- /dev/null +++ b/src-tauri/tests/podman_desktop_documentation_contract.rs @@ -0,0 +1,67 @@ +//! Source-level documentation contract for the Podman desktop evidence module. +//! +//! This test keeps private helpers and regression tests understandable in addition to the public +//! API rustdoc enforced by the module's `missing_docs` lint. + +use std::fs; +use std::path::PathBuf; + +/// Require every named function in the Podman desktop evidence module to have adjacent, +/// beginner-readable rustdoc rather than an empty marker or placeholder text. +#[test] +fn every_podman_desktop_function_has_beginner_readable_rustdoc() { + let source_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/podman_desktop.rs"); + let source = fs::read_to_string(&source_path).expect("podman_desktop.rs must be readable"); + let lines = source.lines().collect::>(); + let mut violations = Vec::new(); + + for (line_index, line) in lines.iter().enumerate() { + let declaration = line.trim_start(); + let is_named_function = declaration.starts_with("fn ") + || declaration.starts_with("pub fn ") + || declaration.starts_with("pub(crate) fn ") + || declaration.starts_with("async fn ") + || declaration.starts_with("pub async fn ") + || declaration.starts_with("unsafe fn ") + || declaration.starts_with("pub unsafe fn ") + || declaration.starts_with("const fn ") + || declaration.starts_with("pub const fn "); + if !is_named_function { + continue; + } + + let mut cursor = line_index; + while cursor > 0 { + let previous = lines[cursor - 1].trim(); + if previous.is_empty() || previous.starts_with("#[") { + cursor -= 1; + continue; + } + break; + } + + let mut rustdoc_lines = Vec::new(); + while cursor > 0 { + let previous = lines[cursor - 1].trim(); + let Some(rustdoc) = previous.strip_prefix("///") else { + break; + }; + rustdoc_lines.push(rustdoc.trim()); + cursor -= 1; + } + rustdoc_lines.reverse(); + let rustdoc = rustdoc_lines.join(" "); + let readable = rustdoc.chars().count() >= 24 + && !rustdoc.to_ascii_lowercase().contains("todo") + && !rustdoc.to_ascii_lowercase().contains("placeholder"); + if !readable { + violations.push(format!("line {}: {declaration}", line_index + 1)); + } + } + + assert!( + violations.is_empty(), + "every Podman desktop function needs adjacent beginner-readable rustdoc; violations: {}", + violations.join(", ") + ); +} From f640e9dd85d56c99ba837c9063cbe9e7fdbc59eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 02:06:55 +0900 Subject: [PATCH 39/66] docs: enforce complete Podman Rust documentation --- src-tauri/src/podman_desktop.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src-tauri/src/podman_desktop.rs b/src-tauri/src/podman_desktop.rs index b5fac98f4..6fb406e44 100644 --- a/src-tauri/src/podman_desktop.rs +++ b/src-tauri/src/podman_desktop.rs @@ -5,6 +5,8 @@ //! that contains measurements and stable issue codes, but never machine names, paths, //! image identifiers, tags, or shell command text. +#![deny(missing_docs)] + use crate::podman_reclaim::{ probe_podman_reclaim, PodmanReclaimPlan, PodmanRecommendedActionKind, DEFAULT_PODMAN_MACHINE, DEFAULT_PROBE_TIMEOUT, @@ -276,6 +278,7 @@ mod tests { PODMAN_RECLAIM_SCHEMA_KIND, }; + /// Build a deterministic Podman `system df` category fixture with one active record. fn category(reclaimable_bytes: u64) -> PodmanSystemDfCategoryEvidence { PodmanSystemDfCategoryEvidence { total: 2, @@ -285,6 +288,7 @@ mod tests { } } + /// Build a complete privacy-sensitive headless plan used by redaction regression tests. fn complete_plan() -> PodmanReclaimPlan { PodmanReclaimPlan { schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, @@ -358,6 +362,7 @@ mod tests { } } + /// Verify that the desktop contract keeps capacity categories separate and redacts local data. #[test] fn projection_keeps_measurements_separate_and_removes_private_context() { let evidence = redact_podman_reclaim_plan(complete_plan()); @@ -382,6 +387,7 @@ mod tests { assert!(!json.contains("/var/home/private")); } + /// Verify that image, stopped-container, and volume review decisions never authorize each other. #[test] fn image_container_and_volume_reviews_remain_separate() { let evidence = redact_podman_reclaim_plan(complete_plan()); @@ -401,6 +407,7 @@ mod tests { assert!(!evidence.review_boundaries.volume_review_required); } + /// Verify that dynamic local diagnostic details are removed and duplicate stable codes collapse. #[test] fn dynamic_issue_details_are_redacted_and_deduplicated() { let mut plan = complete_plan(); @@ -424,6 +431,7 @@ mod tests { .contains("Users/alice")); } + /// Verify that malformed candidate fingerprints fail closed without discarding safe measurements. #[test] fn invalid_fingerprint_fails_closed_without_hiding_other_evidence() { let mut plan = complete_plan(); @@ -437,6 +445,7 @@ mod tests { assert_eq!(evidence.candidates.image_candidate_bytes, Some(200)); } + /// Verify that missing optional observations remain unknown rather than becoming false zeroes. #[test] fn absent_optional_evidence_stays_unknown_instead_of_becoming_zero() { let mut plan = complete_plan(); @@ -464,6 +473,7 @@ mod tests { assert_eq!(evidence.candidates.image_candidate_set_sha256, None); } + /// Verify stable fallback issue codes and canonical lowercase SHA-256 validation. #[test] fn issue_code_fallback_and_fingerprint_validation_are_stable() { assert_eq!(stable_issue_code(""), "podman-evidence-error"); From 07e0f3720a3df5e4d4bf671c4d6c04191b877597 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 02:07:40 +0900 Subject: [PATCH 40/66] docs: record Podman Rust documentation contract --- docs/architecture/podman-desktop-evidence.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/architecture/podman-desktop-evidence.md b/docs/architecture/podman-desktop-evidence.md index beb9e6364..c72b6e897 100644 --- a/docs/architecture/podman-desktop-evidence.md +++ b/docs/architecture/podman-desktop-evidence.md @@ -78,6 +78,7 @@ The desktop response is a versioned JSON contract with no dependency on Naruon o - Transport and JavaScript failures cannot leak machine names, paths, sockets, or command detail through the visible error region. - The architecture can later add separate governed image, container, and volume approval records without changing the read-only evidence contract. - Headless API validation, issue-code privacy tests, error-redaction tests, and view-state tests remain deterministic and are included in the 100% frontend statement, branch, function, and line coverage gate. +- Module-level `missing_docs` enforcement and a source-level Rust documentation contract keep every Podman desktop function, including private helpers and regression tests, beginner-readable. ### Negative @@ -100,7 +101,8 @@ The desktop response is a versioned JSON contract with no dependency on Naruon o | Progress and errors announced | Svelte markup uses `role="status"` and `role="alert"` | | No mutation surface | Registered command list exposes inspection only | | Frontend logic coverage | `vitest.config.ts` includes `podmanEvidence.ts` and `podmanEvidenceError.ts` at 100% thresholds | -| Beginner-readable function documentation | Source-level JSDoc regression test checks every production function declaration | +| Beginner-readable frontend function documentation | Source-level JSDoc regression test checks every production function declaration | +| Beginner-readable Rust function documentation | `missing_docs` rejects undocumented public API and `podman_desktop_documentation_contract.rs` checks every named function | ## Release acceptance From 0d26739170d16f3eec25e72539895460fb6e8db1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 02:07:55 +0900 Subject: [PATCH 41/66] docs: update Podman documentation coverage changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index df379621d..72f6044f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Add a read-only Podman evidence panel to Cleanup that separately displays configured VM capacity, raw-image logical size, host allocation, guest filesystem observations, Podman store observations, image/stopped-container/volume logical candidates, evidence completeness, stable issue codes, and a redacted candidate-set fingerprint. - Add a privacy-safe Tauri contract that removes machine names, local paths, graph-root locations, image identifiers, tags, command output, and dynamic error details before evidence reaches the desktop frontend. - Add beginner-readable JSDoc for every Podman frontend contract function and a deterministic source-level regression test that fails when any production function loses its adjacent documentation. +- Add module-level Rust `missing_docs` enforcement and a deterministic source-level contract that requires beginner-readable rustdoc for every Podman desktop function, including private helpers and regression tests. ### Changed From f73af0a608606c48140c1b719e56beb8b87ed436 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 03:03:12 +0900 Subject: [PATCH 42/66] test: cover crate-visible Rust function modifiers --- src-tauri/tests/podman_desktop_documentation_contract.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src-tauri/tests/podman_desktop_documentation_contract.rs b/src-tauri/tests/podman_desktop_documentation_contract.rs index bf254e575..407f8580b 100644 --- a/src-tauri/tests/podman_desktop_documentation_contract.rs +++ b/src-tauri/tests/podman_desktop_documentation_contract.rs @@ -22,10 +22,13 @@ fn every_podman_desktop_function_has_beginner_readable_rustdoc() { || declaration.starts_with("pub(crate) fn ") || declaration.starts_with("async fn ") || declaration.starts_with("pub async fn ") + || declaration.starts_with("pub(crate) async fn ") || declaration.starts_with("unsafe fn ") || declaration.starts_with("pub unsafe fn ") + || declaration.starts_with("pub(crate) unsafe fn ") || declaration.starts_with("const fn ") - || declaration.starts_with("pub const fn "); + || declaration.starts_with("pub const fn ") + || declaration.starts_with("pub(crate) const fn "); if !is_named_function { continue; } From bf3efdb27bfb06c6b21a66279c7b610cfb44738d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 03:08:39 +0900 Subject: [PATCH 43/66] chore: remove unrelated Rust formatting from Podman slice --- src-tauri/src/bin/disksage-reclaim-plan.rs | 6 +- src-tauri/src/dataset_metadata.rs | 5 +- src-tauri/src/dev_artifacts.rs | 29 +- src-tauri/src/dupes.rs | 145 ++------- src-tauri/src/inventory.rs | 46 +-- src-tauri/src/llm/backend.rs | 25 +- src-tauri/src/llm/engine.rs | 29 +- src-tauri/src/llm/mod.rs | 44 +-- src-tauri/src/llm/model.rs | 5 +- src-tauri/src/llm/parse.rs | 88 ++---- src-tauri/src/llm/prompt.rs | 33 +- src-tauri/src/llm/verdict.rs | 18 +- src-tauri/src/ontology.rs | 283 ++++-------------- src-tauri/src/organize.rs | 118 ++------ src-tauri/src/reasoning.rs | 67 +---- src-tauri/src/rules.rs | 55 +--- src-tauri/src/safety.rs | 148 ++------- src-tauri/src/scanner.rs | 30 +- src-tauri/src/settings.rs | 4 +- src-tauri/src/userrules.rs | 249 +++------------ src-tauri/src/web/mod.rs | 16 +- .../tests/cloud_transfer_coverage_contract.rs | 10 +- ...local_eviction_batch_documentation_test.rs | 3 +- 23 files changed, 313 insertions(+), 1143 deletions(-) diff --git a/src-tauri/src/bin/disksage-reclaim-plan.rs b/src-tauri/src/bin/disksage-reclaim-plan.rs index a8a82b7e4..efd93e99b 100644 --- a/src-tauri/src/bin/disksage-reclaim-plan.rs +++ b/src-tauri/src/bin/disksage-reclaim-plan.rs @@ -132,7 +132,11 @@ mod tests { #[test] fn double_dash_preserves_option_like_paths() { let parsed = expect_run( - parse_args([OsString::from("--"), OsString::from("--not-an-option")]).unwrap(), + parse_args([ + OsString::from("--"), + OsString::from("--not-an-option"), + ]) + .unwrap(), ); assert_eq!(parsed.paths, [PathBuf::from("--not-an-option")]); diff --git a/src-tauri/src/dataset_metadata.rs b/src-tauri/src/dataset_metadata.rs index 277d015ed..bc98047b6 100644 --- a/src-tauri/src/dataset_metadata.rs +++ b/src-tauri/src/dataset_metadata.rs @@ -657,7 +657,10 @@ mod tests { fn spreadsheet_profile_keeps_schema_but_not_cell_values() { let rows = vec![ vec![Data::String("email".into()), Data::String("amount".into())], - vec![Data::String("person@example.com".into()), Data::Int(42)], + vec![ + Data::String("person@example.com".into()), + Data::Int(42), + ], vec![Data::Empty, Data::Float(3.5)], ]; let mut profile = DatasetProfile { diff --git a/src-tauri/src/dev_artifacts.rs b/src-tauri/src/dev_artifacts.rs index 2fe67f356..9d70123a2 100644 --- a/src-tauri/src/dev_artifacts.rs +++ b/src-tauri/src/dev_artifacts.rs @@ -28,9 +28,7 @@ fn artifact_kind(name: &str) -> Option<&'static (&'static str, &'static [&'stati fn age_days(path: &Path, now_ms: u64) -> u64 { let Ok(md) = path.metadata() else { return 0 }; let Ok(mtime) = md.modified() else { return 0 }; - let Ok(dur) = mtime.duration_since(std::time::UNIX_EPOCH) else { - return 0; - }; + let Ok(dur) = mtime.duration_since(std::time::UNIX_EPOCH) else { return 0 }; let mtime_ms = dur.as_millis() as u64; now_ms.saturating_sub(mtime_ms) / 86_400_000 } @@ -59,12 +57,8 @@ pub fn find_artifacts(root: &Path, min_age_days: u64, now_ms: u64) -> Vec Vec = top_level .into_iter() .filter_map(|path| { - let age = if now_ms == u64::MAX { - u64::MAX - } else { - age_days(path, now_ms) - }; + let age = if now_ms == u64::MAX { u64::MAX } else { age_days(path, now_ms) }; if age < min_age_days { return None; } @@ -101,9 +91,7 @@ pub fn find_artifacts(root: &Path, min_age_days: u64, now_ms: u64) -> Vec std::path::PathBuf { + fn project(root: &std::path::Path, name: &str, marker: &str, artifact: &str) -> std::path::PathBuf { let p = root.join(name); fs::create_dir_all(&p).unwrap(); fs::write(p.join(marker), b"{}").unwrap(); diff --git a/src-tauri/src/dupes.rs b/src-tauri/src/dupes.rs index 97e92bb7b..8e200eb11 100644 --- a/src-tauri/src/dupes.rs +++ b/src-tauri/src/dupes.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use std::io::Read; use std::path::{Path, PathBuf}; +use std::io::Read; #[derive(Debug, Clone)] pub struct FileEntry { @@ -11,8 +11,7 @@ pub struct FileEntry { /// Metadata의 수정시각 → epoch millis. 지원 안 되면 0 (플랫폼별 실패는 드묾; 0 폴백). fn mtime_millis(md: &std::fs::Metadata) -> u64 { - md.modified() - .ok() + md.modified().ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_millis() as u64) .unwrap_or(0) @@ -28,11 +27,8 @@ pub fn group_by_size(files: Vec) -> Vec> { } by_size.entry(f.size).or_default().push(f); } - let mut groups: Vec> = by_size - .into_iter() - .filter(|(_, v)| v.len() >= 2) - .map(|(_, v)| v) - .collect(); + let mut groups: Vec> = + by_size.into_iter().filter(|(_, v)| v.len() >= 2).map(|(_, v)| v).collect(); groups.sort_by(|a, b| b[0].size.cmp(&a[0].size)); groups } @@ -114,12 +110,8 @@ pub fn find_duplicates(files: Vec, prefix_len: usize) -> Vec=2로 생성되지만, 다른 곳에서 만들어져도 패닉 없이 - let wa = a - .size - .saturating_mul((a.paths.len() as u64).saturating_sub(1)); - let wb = b - .size - .saturating_mul((b.paths.len() as u64).saturating_sub(1)); + let wa = a.size.saturating_mul((a.paths.len() as u64).saturating_sub(1)); + let wb = b.size.saturating_mul((b.paths.len() as u64).saturating_sub(1)); wb.cmp(&wa) }); out @@ -137,28 +129,18 @@ pub fn collect_files(root: &Path) -> Vec { .into_iter() .filter_map(Result::ok) .filter(|e| e.file_type().is_file()) - .filter_map(|e| { - e.metadata().ok().map(|md| FileEntry { - path: e.path(), - size: md.len(), - mtime_ms: mtime_millis(&md), - }) - }) + .filter_map(|e| e.metadata().ok().map(|md| FileEntry { path: e.path(), size: md.len(), mtime_ms: mtime_millis(&md) })) .collect() } #[cfg(test)] mod tests { use super::*; - use std::io::Write; use std::path::PathBuf; + use std::io::Write; fn fe(p: &str, size: u64) -> FileEntry { - FileEntry { - path: PathBuf::from(p), - size, - mtime_ms: 0, - } + FileEntry { path: PathBuf::from(p), size, mtime_ms: 0 } } fn write_file(dir: &std::path::Path, name: &str, bytes: &[u8]) -> PathBuf { @@ -173,9 +155,9 @@ mod tests { let files = vec![ fe("/a", 100), fe("/b", 100), - fe("/c", 50), // 단독 — 제외 - fe("/d", 0), // 0바이트 — 제외 - fe("/e", 0), // 0바이트 — 제외 + fe("/c", 50), // 단독 — 제외 + fe("/d", 0), // 0바이트 — 제외 + fe("/e", 0), // 0바이트 — 제외 fe("/f", 100), ]; let groups = group_by_size(files); @@ -187,7 +169,10 @@ mod tests { #[test] fn multiple_size_groups_sorted_desc() { - let files = vec![fe("/a", 10), fe("/b", 10), fe("/c", 999), fe("/d", 999)]; + let files = vec![ + fe("/a", 10), fe("/b", 10), + fe("/c", 999), fe("/d", 999), + ]; let groups = group_by_size(files); assert_eq!(groups.len(), 2); // 그룹은 크기 내림차순: 999 그룹이 먼저 @@ -245,31 +230,11 @@ mod tests { let solo = write_file(tmp.path(), "solo", b"different length entirely"); let files = vec![ - FileEntry { - path: d1, - size: 16, - mtime_ms: 0, - }, - FileEntry { - path: d2, - size: 16, - mtime_ms: 0, - }, - FileEntry { - path: n1, - size: 16, - mtime_ms: 0, - }, - FileEntry { - path: n2, - size: 16, - mtime_ms: 0, - }, - FileEntry { - path: solo.clone(), - size: std::fs::metadata(&solo).unwrap().len(), - mtime_ms: 0, - }, + FileEntry { path: d1, size: 16, mtime_ms: 0 }, + FileEntry { path: d2, size: 16, mtime_ms: 0 }, + FileEntry { path: n1, size: 16, mtime_ms: 0 }, + FileEntry { path: n2, size: 16, mtime_ms: 0 }, + FileEntry { path: solo.clone(), size: std::fs::metadata(&solo).unwrap().len(), mtime_ms: 0 }, ]; let groups = find_duplicates(files, 8); @@ -280,13 +245,7 @@ mod tests { let names: Vec = groups[0] .paths .iter() - .map(|p| { - std::path::Path::new(p) - .file_name() - .unwrap() - .to_string_lossy() - .into_owned() - }) + .map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().into_owned()) .collect(); assert!(names.contains(&"d1".to_string()) && names.contains(&"d2".to_string())); } @@ -301,26 +260,10 @@ mod tests { let s1 = write_file(tmp.path(), "s1", b"tenbytes!!"); let s2 = write_file(tmp.path(), "s2", b"tenbytes!!"); let files = vec![ - FileEntry { - path: b1, - size: 1000, - mtime_ms: 0, - }, - FileEntry { - path: b2, - size: 1000, - mtime_ms: 0, - }, - FileEntry { - path: s1, - size: 10, - mtime_ms: 0, - }, - FileEntry { - path: s2, - size: 10, - mtime_ms: 0, - }, + FileEntry { path: b1, size: 1000, mtime_ms: 0 }, + FileEntry { path: b2, size: 1000, mtime_ms: 0 }, + FileEntry { path: s1, size: 10, mtime_ms: 0 }, + FileEntry { path: s2, size: 10, mtime_ms: 0 }, ]; let groups = find_duplicates(files, 4096); assert_eq!(groups.len(), 2); @@ -334,16 +277,8 @@ mod tests { let a = write_file(tmp.path(), "a", b"AAAA1111"); let b = write_file(tmp.path(), "b", b"BBBB2222"); let files = vec![ - FileEntry { - path: a, - size: 8, - mtime_ms: 0, - }, - FileEntry { - path: b, - size: 8, - mtime_ms: 0, - }, + FileEntry { path: a, size: 8, mtime_ms: 0 }, + FileEntry { path: b, size: 8, mtime_ms: 0 }, ]; assert!(find_duplicates(files, 4).is_empty()); } @@ -354,21 +289,9 @@ mod tests { let d1 = write_file(tmp.path(), "d1", b"same content x"); let d2 = write_file(tmp.path(), "d2", b"same content x"); let files = vec![ - FileEntry { - path: d1, - size: 14, - mtime_ms: 0, - }, - FileEntry { - path: d2, - size: 14, - mtime_ms: 0, - }, - FileEntry { - path: tmp.path().join("ghost"), - size: 14, - mtime_ms: 0, - }, // 존재하지 않음 + FileEntry { path: d1, size: 14, mtime_ms: 0 }, + FileEntry { path: d2, size: 14, mtime_ms: 0 }, + FileEntry { path: tmp.path().join("ghost"), size: 14, mtime_ms: 0 }, // 존재하지 않음 ]; // ghost는 크기 그룹엔 들어가지만 해시 단계서 실패 → 조용히 빠지고 d1/d2는 확정 let groups = find_duplicates(files, 4096); @@ -400,10 +323,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); write_file(tmp.path(), "x.bin", b"data"); let files = collect_files(tmp.path()); - assert!( - files.iter().any(|f| f.mtime_ms > 0), - "mtime_ms filled for a real file" - ); + assert!(files.iter().any(|f| f.mtime_ms > 0), "mtime_ms filled for a real file"); } #[cfg(unix)] @@ -425,4 +345,5 @@ mod tests { assert!(names.contains(&"nested.bin".to_string())); assert!(!names.contains(&"link.bin".to_string()), "심링크 제외"); } + } diff --git a/src-tauri/src/inventory.rs b/src-tauri/src/inventory.rs index 318b3ffb5..9537b1f5b 100644 --- a/src-tauri/src/inventory.rs +++ b/src-tauri/src/inventory.rs @@ -75,26 +75,12 @@ pub fn build_inventory(files: &[FileEntry], onto: &Ontology) -> InventoryReport let mut tallies: Vec = acc .into_iter() - .map(|(class_id, (label, bytes, count))| ClassTally { - class_id, - label, - bytes, - count, - }) + .map(|(class_id, (label, bytes, count))| ClassTally { class_id, label, bytes, count }) .collect(); // bytes 내림차순, 동점은 class_id로 결정적 정렬(HashMap 순서 무작위성 → UI 깜빡임 방지) - tallies.sort_by(|a, b| { - b.bytes - .cmp(&a.bytes) - .then_with(|| a.class_id.cmp(&b.class_id)) - }); - - InventoryReport { - tallies, - unknown_bytes, - unknown_count, - unknown_samples, - } + tallies.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.class_id.cmp(&b.class_id))); + + InventoryReport { tallies, unknown_bytes, unknown_count, unknown_samples } } #[cfg(test)] @@ -113,11 +99,7 @@ dm:Code a owl:Class ; rdfs:label "코드"@ko . "#; fn fe(p: &str, size: u64) -> FileEntry { - FileEntry { - path: PathBuf::from(p), - size, - mtime_ms: 0, - } + FileEntry { path: PathBuf::from(p), size, mtime_ms: 0 } } #[test] @@ -125,7 +107,7 @@ dm:Code a owl:Class ; rdfs:label "코드"@ko . // Image assert_eq!(classify(&PathBuf::from("/x/a.png")), Some("Image")); assert_eq!(classify(&PathBuf::from("/x/b.JPG")), Some("Image")); // 대소문자 무관 - // Code + // Code assert_eq!(classify(&PathBuf::from("/x/c.rs")), Some("Code")); // Video assert_eq!(classify(&PathBuf::from("/x/movie.mp4")), Some("Video")); @@ -146,9 +128,9 @@ dm:Code a owl:Class ; rdfs:label "코드"@ko . let onto = parse_ttl(ONTO).unwrap(); let files = vec![ fe("/a.png", 100), - fe("/b.png", 200), // Image 합계 300, count 2 - fe("/c.rs", 50), // Code 50, count 1 - fe("/d.xyz", 999), // 미분류 → unknown + fe("/b.png", 200), // Image 합계 300, count 2 + fe("/c.rs", 50), // Code 50, count 1 + fe("/d.xyz", 999), // 미분류 → unknown ]; let rep = build_inventory(&files, &onto); // Unknown은 일급 필드 @@ -156,16 +138,10 @@ dm:Code a owl:Class ; rdfs:label "코드"@ko . assert_eq!(rep.unknown_count, 1); assert_eq!(rep.unknown_samples, vec!["/d.xyz".to_string()]); // tallies는 바이트 내림차순: Image(300) > Code(50) - assert_eq!( - rep.tallies[0].class_id, - "https://disksage.app/ontology#Image" - ); + assert_eq!(rep.tallies[0].class_id, "https://disksage.app/ontology#Image"); assert_eq!(rep.tallies[0].bytes, 300); assert_eq!(rep.tallies[0].count, 2); - assert_eq!( - rep.tallies[1].class_id, - "https://disksage.app/ontology#Code" - ); + assert_eq!(rep.tallies[1].class_id, "https://disksage.app/ontology#Code"); } #[test] diff --git a/src-tauri/src/llm/backend.rs b/src-tauri/src/llm/backend.rs index b4a65afcc..12859f348 100644 --- a/src-tauri/src/llm/backend.rs +++ b/src-tauri/src/llm/backend.rs @@ -29,14 +29,8 @@ mod tests { use super::*; #[test] fn prefers_cuda_then_vulkan_then_cpu() { - assert_eq!( - choose_backend(&[Backend::Cpu, Backend::Vulkan, Backend::Cuda], None), - Backend::Cuda - ); - assert_eq!( - choose_backend(&[Backend::Cpu, Backend::Vulkan], None), - Backend::Vulkan - ); + assert_eq!(choose_backend(&[Backend::Cpu, Backend::Vulkan, Backend::Cuda], None), Backend::Cuda); + assert_eq!(choose_backend(&[Backend::Cpu, Backend::Vulkan], None), Backend::Vulkan); assert_eq!(choose_backend(&[Backend::Cpu], None), Backend::Cpu); } #[test] @@ -45,24 +39,15 @@ mod tests { } #[test] fn honors_override_when_available() { - assert_eq!( - choose_backend(&[Backend::Cpu, Backend::Cuda], Some(Backend::Cpu)), - Backend::Cpu - ); + assert_eq!(choose_backend(&[Backend::Cpu, Backend::Cuda], Some(Backend::Cpu)), Backend::Cpu); } #[test] fn ignores_override_when_unavailable() { - assert_eq!( - choose_backend(&[Backend::Cpu], Some(Backend::Cuda)), - Backend::Cpu - ); + assert_eq!(choose_backend(&[Backend::Cpu], Some(Backend::Cuda)), Backend::Cpu); } #[test] fn prefers_metal_over_vulkan() { // Metal이 Vulkan보다 우선 — choose_backend의 Metal 분기 커버 - assert_eq!( - choose_backend(&[Backend::Vulkan, Backend::Metal, Backend::Cpu], None), - Backend::Metal - ); + assert_eq!(choose_backend(&[Backend::Vulkan, Backend::Metal, Backend::Cpu], None), Backend::Metal); } } diff --git a/src-tauri/src/llm/engine.rs b/src-tauri/src/llm/engine.rs index 88a1ebf5f..b0ad3e2f2 100644 --- a/src-tauri/src/llm/engine.rs +++ b/src-tauri/src/llm/engine.rs @@ -31,15 +31,15 @@ impl LlamaEngine { .and_then(|v| v.parse().ok()) .unwrap_or(999); let params = LlamaModelParams::default().with_n_gpu_layers(gpu_layers); - let model = - LlamaModel::load_from_file(&backend, model_path, ¶ms).map_err(|e| e.to_string())?; + let model = LlamaModel::load_from_file(&backend, model_path, ¶ms).map_err(|e| e.to_string())?; Ok(Self { backend, model }) } } impl InferenceEngine for LlamaEngine { fn infer(&self, prompt: &str) -> Result { - let ctx_params = LlamaContextParams::default().with_n_ctx(NonZeroU32::new(N_CTX)); + let ctx_params = LlamaContextParams::default() + .with_n_ctx(NonZeroU32::new(N_CTX)); let mut ctx = self .model .new_context(&self.backend, ctx_params) @@ -53,9 +53,7 @@ impl InferenceEngine for LlamaEngine { let mut batch = LlamaBatch::new(512, 1); let last = tokens.len().saturating_sub(1); for (i, tok) in tokens.iter().enumerate() { - batch - .add(*tok, i as i32, &[0], i == last) - .map_err(|e| e.to_string())?; + batch.add(*tok, i as i32, &[0], i == last).map_err(|e| e.to_string())?; } ctx.decode(&mut batch).map_err(|e| e.to_string())?; @@ -74,16 +72,9 @@ impl InferenceEngine for LlamaEngine { // requires threading an encoding_rs::Decoder through the caller. Not worth a new // dependency just to silence a warning for a single-token-at-a-time greedy loop. #[allow(deprecated)] - out.push_str( - &self - .model - .token_to_str(token, Special::Tokenize) - .map_err(|e| e.to_string())?, - ); + out.push_str(&self.model.token_to_str(token, Special::Tokenize).map_err(|e| e.to_string())?); batch.clear(); - batch - .add(token, n_cur, &[0], true) - .map_err(|e| e.to_string())?; + batch.add(token, n_cur, &[0], true).map_err(|e| e.to_string())?; n_cur += 1; generated += 1; ctx.decode(&mut batch).map_err(|e| e.to_string())?; @@ -102,13 +93,7 @@ mod tests { fn real_engine_returns_a_rated_verdict() { let path = std::env::var("DISKSAGE_MODEL").expect("set DISKSAGE_MODEL to a .gguf path"); let engine = LlamaEngine::new(std::path::Path::new(&path)).unwrap(); - let meta = FileMeta { - path: "/tmp/x.log".into(), - name: "x.log".into(), - size: 10, - mtime_days: 1, - parent: "tmp".into(), - }; + let meta = FileMeta { path: "/tmp/x.log".into(), name: "x.log".into(), size: 10, mtime_days: 1, parent: "tmp".into() }; let fv = verdict_for(&engine, &meta); assert_ne!(fv.verdict, Verdict::Unrated); // 실제 모델이면 safe/caution/keep 중 하나 } diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index dbb2d4bdf..e532148bc 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -20,13 +20,9 @@ pub use model::{verify_sha256, ModelSpec, DEFAULT}; #[cfg(not(coverage))] pub use model::download_to; #[cfg_attr(coverage, allow(unused_imports))] -pub use parse::{ - parse_class_pick, parse_ext_reasoning, parse_summary, parse_verdict, parse_verdict_full, -}; +pub use parse::{parse_class_pick, parse_ext_reasoning, parse_summary, parse_verdict, parse_verdict_full}; #[cfg_attr(coverage, allow(unused_imports))] -pub use prompt::{ - classify_prompt, ext_reason_prompt, summary_prompt, verdict_prompt, ExtReasoning, FileMeta, -}; +pub use prompt::{classify_prompt, ext_reason_prompt, summary_prompt, verdict_prompt, ExtReasoning, FileMeta}; #[cfg_attr(coverage, allow(unused_imports))] pub use verdict::{FileVerdict, Verdict}; @@ -43,19 +39,11 @@ pub fn verdict_for(engine: &dyn InferenceEngine, meta: &FileMeta) -> FileVerdict Ok(out) => parse_verdict_full(&out), Err(_) => (Verdict::Unrated, String::new()), }; - FileVerdict { - path: meta.path.clone(), - verdict, - reason, - } + FileVerdict { path: meta.path.clone(), verdict, reason } } /// 후보 목록 중 클래스 선택. infer 실패·범위 밖은 None(자유 생성 거부). -pub fn pick_class( - engine: &dyn InferenceEngine, - meta: &FileMeta, - candidates: &[&str], -) -> Option { +pub fn pick_class(engine: &dyn InferenceEngine, meta: &FileMeta, candidates: &[&str]) -> Option { let out = engine.infer(&classify_prompt(meta, candidates)).ok()?; parse_class_pick(&out, candidates) } @@ -67,11 +55,7 @@ pub fn summarize_unknown(engine: &dyn InferenceEngine, samples: &[FileMeta]) -> } /// 확장자 하나를 추론(type + 제안 class). infer 실패·파싱 실패는 None. -pub fn reason_extension( - engine: &dyn InferenceEngine, - ext: &str, - candidates: &[&str], -) -> Option { +pub fn reason_extension(engine: &dyn InferenceEngine, ext: &str, candidates: &[&str]) -> Option { let out = engine.infer(&ext_reason_prompt(ext, candidates)).ok()?; parse_ext_reasoning(&out, candidates) } @@ -82,18 +66,11 @@ mod tests { struct Fake(Result); impl InferenceEngine for Fake { - fn infer(&self, _p: &str) -> Result { - self.0.clone() - } + fn infer(&self, _p: &str) -> Result { self.0.clone() } } fn meta() -> FileMeta { - FileMeta { - path: "/downloads/old_report.pdf".into(), - name: "old_report.pdf".into(), - size: 2_400_000, - mtime_days: 420, - parent: "downloads".into(), - } + FileMeta { path: "/downloads/old_report.pdf".into(), name: "old_report.pdf".into(), + size: 2_400_000, mtime_days: 420, parent: "downloads".into() } } #[test] @@ -114,10 +91,7 @@ mod tests { #[test] fn pick_class_returns_candidate() { let e = Fake(Ok(r#"{"class":"Image"}"#.into())); - assert_eq!( - pick_class(&e, &meta(), &["Image", "Doc"]), - Some("Image".into()) - ); + assert_eq!(pick_class(&e, &meta(), &["Image", "Doc"]), Some("Image".into())); } #[test] fn pick_class_rejects_out_of_list() { diff --git a/src-tauri/src/llm/model.rs b/src-tauri/src/llm/model.rs index a8913290a..5005eb25d 100644 --- a/src-tauri/src/llm/model.rs +++ b/src-tauri/src/llm/model.rs @@ -40,10 +40,7 @@ pub fn download_to(spec: &ModelSpec, dest: &std::path::Path) -> Result<(), Strin let mut buf = Vec::new(); reader.read_to_end(&mut buf).map_err(|e| e.to_string())?; if !verify_sha256(&buf, spec.sha256_hex) { - return Err(format!( - "SHA-256 불일치 — 손상되었거나 예상과 다른 파일: {}", - spec.name - )); + return Err(format!("SHA-256 불일치 — 손상되었거나 예상과 다른 파일: {}", spec.name)); } std::fs::write(&part, &buf).map_err(|e| e.to_string())?; std::fs::rename(&part, dest).map_err(|e| e.to_string())?; diff --git a/src-tauri/src/llm/parse.rs b/src-tauri/src/llm/parse.rs index df0f3a17c..eccc1e471 100644 --- a/src-tauri/src/llm/parse.rs +++ b/src-tauri/src/llm/parse.rs @@ -12,9 +12,7 @@ fn extract_json(raw: &str) -> Option<&str> { depth += 1; } else if bytes[i] == b'}' { depth -= 1; - if depth == 0 { - return Some(&raw[start..=i]); - } + if depth == 0 { return Some(&raw[start..=i]); } } } None @@ -27,17 +25,9 @@ pub fn parse_verdict(raw: &str) -> Verdict { /// (판정, 이유). 실패 시 (Unrated, "")로 fail-closed. pub fn parse_verdict_full(raw: &str) -> (Verdict, String) { - let Some(js) = extract_json(raw) else { - return (Verdict::Unrated, String::new()); - }; - let Ok(v) = serde_json::from_str::(js) else { - return (Verdict::Unrated, String::new()); - }; - let reason = v - .get("reason") - .and_then(|r| r.as_str()) - .unwrap_or("") - .to_string(); + let Some(js) = extract_json(raw) else { return (Verdict::Unrated, String::new()); }; + let Ok(v) = serde_json::from_str::(js) else { return (Verdict::Unrated, String::new()); }; + let reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or("").to_string(); let verdict = match v.get("verdict").and_then(|x| x.as_str()) { Some("safe") => Verdict::Safe, Some("caution") => Verdict::Caution, @@ -71,11 +61,7 @@ pub fn parse_ext_reasoning(raw: &str, candidates: &[&str]) -> Option(js).ok()?; let type_desc = v.get("type")?.as_str()?.to_string(); - let class = v - .get("class") - .and_then(|c| c.as_str()) - .filter(|c| candidates.contains(c)) - .map(|c| c.to_string()); + let class = v.get("class").and_then(|c| c.as_str()).filter(|c| candidates.contains(c)).map(|c| c.to_string()); Some(ExtReasoning { type_desc, class }) } @@ -84,10 +70,7 @@ mod tests { use super::*; #[test] fn parses_clean_json() { - assert_eq!( - parse_verdict(r#"{"verdict":"safe","reason":"cache file"}"#), - Verdict::Safe - ); + assert_eq!(parse_verdict(r#"{"verdict":"safe","reason":"cache file"}"#), Verdict::Safe); assert_eq!(parse_verdict(r#"{"verdict":"caution"}"#), Verdict::Caution); assert_eq!(parse_verdict(r#"{"verdict":"keep"}"#), Verdict::Keep); } @@ -95,10 +78,7 @@ mod tests { fn parses_nested_json_object() { // 중첩 객체 — 안쪽 {..}가 먼저 닫혀 depth가 0이 아닌 값으로 감소하는 fall-through 경로 커버. // extract_json은 바깥 객체 전체를 반환해야 한다. - assert_eq!( - parse_verdict(r#"{"verdict":"safe","meta":{"x":1}}"#), - Verdict::Safe - ); + assert_eq!(parse_verdict(r#"{"verdict":"safe","meta":{"x":1}}"#), Verdict::Safe); } #[test] @@ -108,23 +88,14 @@ mod tests { } #[test] fn verdict_full_returns_reason() { - assert_eq!( - parse_verdict_full(r#"{"verdict":"safe","reason":"tmp"}"#), - (Verdict::Safe, "tmp".to_string()) - ); + assert_eq!(parse_verdict_full(r#"{"verdict":"safe","reason":"tmp"}"#), (Verdict::Safe, "tmp".to_string())); // reason 없으면 빈 문자열 - assert_eq!( - parse_verdict_full(r#"{"verdict":"safe"}"#), - (Verdict::Safe, String::new()) - ); + assert_eq!(parse_verdict_full(r#"{"verdict":"safe"}"#), (Verdict::Safe, String::new())); } #[test] fn unknown_verdict_value_is_unrated() { assert_eq!(parse_verdict(r#"{"verdict":"delete"}"#), Verdict::Unrated); // 알 수 없는 값 - assert_eq!( - parse_verdict(r#"{"note":"no verdict field"}"#), - Verdict::Unrated - ); // 필드 없음 + assert_eq!(parse_verdict(r#"{"note":"no verdict field"}"#), Verdict::Unrated); // 필드 없음 } #[test] fn no_braces_is_unrated() { @@ -141,43 +112,32 @@ mod tests { } #[test] fn class_pick_only_from_candidates() { - assert_eq!( - parse_class_pick(r#"{"class":"Image"}"#, &["Image", "Doc"]), - Some("Image".into()) - ); - assert_eq!( - parse_class_pick(r#"{"class":"Video"}"#, &["Image", "Doc"]), - None - ); // 자유 생성 거부 + assert_eq!(parse_class_pick(r#"{"class":"Image"}"#, &["Image","Doc"]), Some("Image".into())); + assert_eq!(parse_class_pick(r#"{"class":"Video"}"#, &["Image","Doc"]), None); // 자유 생성 거부 } #[test] fn class_pick_failure_paths_are_none() { - assert_eq!(parse_class_pick("no json", &["Image"]), None); // extract None - assert_eq!(parse_class_pick("{bad json}", &["Image"]), None); // serde err + assert_eq!(parse_class_pick("no json", &["Image"]), None); // extract None + assert_eq!(parse_class_pick("{bad json}", &["Image"]), None); // serde err assert_eq!(parse_class_pick(r#"{"other":"x"}"#, &["Image"]), None); // class 필드 없음 - assert_eq!(parse_class_pick(r#"{"class":5}"#, &["Image"]), None); // class가 문자열 아님 + assert_eq!(parse_class_pick(r#"{"class":5}"#, &["Image"]), None); // class가 문자열 아님 } #[test] fn summary_extracted_or_none() { - assert_eq!( - parse_summary(r#"{"summary":"old installers"}"#), - Some("old installers".into()) - ); - assert_eq!(parse_summary("no json"), None); // extract None - assert_eq!(parse_summary("{bad}"), None); // serde err - assert_eq!(parse_summary(r#"{"x":1}"#), None); // summary 필드 없음 + assert_eq!(parse_summary(r#"{"summary":"old installers"}"#), Some("old installers".into())); + assert_eq!(parse_summary("no json"), None); // extract None + assert_eq!(parse_summary("{bad}"), None); // serde err + assert_eq!(parse_summary(r#"{"x":1}"#), None); // summary 필드 없음 assert_eq!(parse_summary(r#"{"summary":9}"#), None); // 문자열 아님 } #[test] fn ext_reasoning_extracts_type_and_validates_class() { // class가 후보에 있으면 Some - let r = - parse_ext_reasoning(r#"{"type":"3D model","class":"Model3D"}"#, &["Model3D"]).unwrap(); + let r = parse_ext_reasoning(r#"{"type":"3D model","class":"Model3D"}"#, &["Model3D"]).unwrap(); assert_eq!(r.type_desc, "3D model"); assert_eq!(r.class.as_deref(), Some("Model3D")); // class가 후보 밖이면 type은 유지, class는 None(자유 생성 거부) - let r2 = - parse_ext_reasoning(r#"{"type":"3D model","class":"Nope"}"#, &["Model3D"]).unwrap(); + let r2 = parse_ext_reasoning(r#"{"type":"3D model","class":"Nope"}"#, &["Model3D"]).unwrap(); assert_eq!(r2.class, None); assert_eq!(r2.type_desc, "3D model"); // class:"none" → None @@ -186,9 +146,9 @@ mod tests { } #[test] fn ext_reasoning_failure_paths_are_none() { - assert!(parse_ext_reasoning("no json", &["X"]).is_none()); // extract None - assert!(parse_ext_reasoning("{bad}", &["X"]).is_none()); // serde err + assert!(parse_ext_reasoning("no json", &["X"]).is_none()); // extract None + assert!(parse_ext_reasoning("{bad}", &["X"]).is_none()); // serde err assert!(parse_ext_reasoning(r#"{"class":"X"}"#, &["X"]).is_none()); // type 필드 없음 → None - assert!(parse_ext_reasoning(r#"{"type":9}"#, &["X"]).is_none()); // type이 문자열 아님 + assert!(parse_ext_reasoning(r#"{"type":9}"#, &["X"]).is_none()); // type이 문자열 아님 } } diff --git a/src-tauri/src/llm/prompt.rs b/src-tauri/src/llm/prompt.rs index 2a3ac2277..d60a4249a 100644 --- a/src-tauri/src/llm/prompt.rs +++ b/src-tauri/src/llm/prompt.rs @@ -37,9 +37,7 @@ pub fn classify_prompt(m: &FileMeta, candidates: &[&str]) -> String { Candidates: {list}\n\ Reply with ONLY this JSON (choose exactly one id from the list above):\n\ {{\"class\":\"\"}}", - name = m.name, - parent = m.parent, - list = candidates.join(", ") + name = m.name, parent = m.parent, list = candidates.join(", ") ) } @@ -71,13 +69,8 @@ pub fn ext_reason_prompt(ext: &str, candidates: &[&str]) -> String { mod tests { use super::*; fn meta() -> FileMeta { - FileMeta { - path: "/downloads/old_report.pdf".into(), - name: "old_report.pdf".into(), - size: 2_400_000, - mtime_days: 420, - parent: "downloads".into(), - } + FileMeta { path: "/downloads/old_report.pdf".into(), name: "old_report.pdf".into(), + size: 2_400_000, mtime_days: 420, parent: "downloads".into() } } #[test] fn verdict_prompt_has_metadata_and_schema() { @@ -90,9 +83,7 @@ mod tests { #[test] fn classify_prompt_lists_all_candidates_and_forbids_free_text() { let p = classify_prompt(&meta(), &["Image", "Document", "Installer"]); - for c in ["Image", "Document", "Installer"] { - assert!(p.contains(c)); - } + for c in ["Image", "Document", "Installer"] { assert!(p.contains(c)); } assert!(p.to_lowercase().contains("exactly one")); } #[test] @@ -102,20 +93,8 @@ mod tests { } #[test] fn summary_prompt_handles_multiple_samples() { - let a = FileMeta { - path: "/a/x.bin".into(), - name: "x.bin".into(), - size: 1, - mtime_days: 1, - parent: "a".into(), - }; - let b = FileMeta { - path: "/a/y.dat".into(), - name: "y.dat".into(), - size: 2, - mtime_days: 2, - parent: "a".into(), - }; + let a = FileMeta { path: "/a/x.bin".into(), name: "x.bin".into(), size: 1, mtime_days: 1, parent: "a".into() }; + let b = FileMeta { path: "/a/y.dat".into(), name: "y.dat".into(), size: 2, mtime_days: 2, parent: "a".into() }; let p = summary_prompt(&[a, b]); assert!(p.contains("x.bin") && p.contains("y.dat")); } diff --git a/src-tauri/src/llm/verdict.rs b/src-tauri/src/llm/verdict.rs index 392c05897..900f7dded 100644 --- a/src-tauri/src/llm/verdict.rs +++ b/src-tauri/src/llm/verdict.rs @@ -21,26 +21,14 @@ mod tests { use super::*; #[test] fn verdict_serde_roundtrip() { - for v in [ - Verdict::Safe, - Verdict::Caution, - Verdict::Keep, - Verdict::Unrated, - ] { + for v in [Verdict::Safe, Verdict::Caution, Verdict::Keep, Verdict::Unrated] { let s = serde_json::to_string(&v).unwrap(); assert_eq!(serde_json::from_str::(&s).unwrap(), v); } // 프런트엔드가 소문자 문자열 리터럴로 switch하므로 와이어 포맷을 고정한다. assert_eq!(serde_json::to_string(&Verdict::Safe).unwrap(), "\"safe\""); - assert_eq!( - serde_json::to_string(&Verdict::Unrated).unwrap(), - "\"unrated\"" - ); - let fv = FileVerdict { - path: "/a".into(), - verdict: Verdict::Safe, - reason: "cache".into(), - }; + assert_eq!(serde_json::to_string(&Verdict::Unrated).unwrap(), "\"unrated\""); + let fv = FileVerdict { path: "/a".into(), verdict: Verdict::Safe, reason: "cache".into() }; let s = serde_json::to_string(&fv).unwrap(); assert_eq!(serde_json::from_str::(&s).unwrap(), fv); } diff --git a/src-tauri/src/ontology.rs b/src-tauri/src/ontology.rs index 4343cb4d6..5aa03998f 100644 --- a/src-tauri/src/ontology.rs +++ b/src-tauri/src/ontology.rs @@ -105,34 +105,28 @@ pub fn parse_ttl(turtle_src: &str) -> Result { #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub enum Issue { /// C ⊑ c1, C ⊑ c2, c1 disjointWith c2 ⇒ C ⊑ owl:Nothing (sound TBox consequence of cax-dw). - UnsatisfiableClass { - class: String, - via_disjoint: (String, String), - }, + UnsatisfiableClass { class: String, via_disjoint: (String, String) }, } pub struct Reasoner { - rep: BTreeMap, // class id → equivalence representative + rep: BTreeMap, // class id → equivalence representative groups: BTreeMap>, // rep → sorted members sup: BTreeMap>, // rep → direct super-reps (acyclic after fixpoint) disjoint_pairs: Vec<(String, String)>, // raw (subject, disjointWith-object) axiom ids, captured at build time - // (target_folder is read from the Ontology, only needed by Ontology::resolve_target) + // (target_folder is read from the Ontology, only needed by Ontology::resolve_target) } impl Reasoner { pub fn build(onto: &Ontology) -> Reasoner { let ids: Vec = onto.classes.iter().map(|c| c.id.clone()).collect(); // union-find - let mut rep: BTreeMap = - ids.iter().map(|i| (i.clone(), i.clone())).collect(); + let mut rep: BTreeMap = ids.iter().map(|i| (i.clone(), i.clone())).collect(); fn find(rep: &mut BTreeMap, x: &str) -> String { // ponytail: every call site below only ever passes an id already seeded into `rep` // (either a class id from `ids`, or a `contains_key`-guarded axiom target) and `union` // only ever overwrites existing keys, so `x` is always present — no silent fallback needed. let p = rep[x].clone(); - if p == x { - return p; - } + if p == x { return p; } let r = find(rep, &p); rep.insert(x.to_string(), r.clone()); r @@ -148,9 +142,7 @@ impl Reasoner { // scm-eqc1: explicit equivalentClass pairs (only among known classes) for c in &onto.classes { for e in &c.equivalents { - if rep.contains_key(e) { - union(&mut rep, &c.id, e); - } + if rep.contains_key(e) { union(&mut rep, &c.id, e); } } } // scm-eqc2 fixpoint: collapse subClassOf cycles among representatives until none remain @@ -160,50 +152,25 @@ impl Reasoner { for c in &onto.classes { let rc = find(&mut rep, &c.id); for p in &c.parents { - if !rep.contains_key(p) { - continue; - } + if !rep.contains_key(p) { continue; } let rp = find(&mut rep, p); - if rc != rp { - edges.insert((rc.clone(), rp.clone())); - } + if rc != rp { edges.insert((rc.clone(), rp.clone())); } } } // reachability on rep graph let mut reach: BTreeMap> = BTreeMap::new(); - for (u, v) in &edges { - reach.entry(u.clone()).or_default().insert(v.clone()); - } - let nodes: BTreeSet = edges - .iter() - .flat_map(|(u, v)| [u.clone(), v.clone()]) - .collect(); + for (u, v) in &edges { reach.entry(u.clone()).or_default().insert(v.clone()); } + let nodes: BTreeSet = edges.iter().flat_map(|(u, v)| [u.clone(), v.clone()]).collect(); loop { let mut changed = false; for n in &nodes { - let outs: Vec = reach - .get(n) - .cloned() - .unwrap_or_default() - .into_iter() - .collect(); + let outs: Vec = reach.get(n).cloned().unwrap_or_default().into_iter().collect(); for m in outs { - let ms: Vec = reach - .get(&m) - .cloned() - .unwrap_or_default() - .into_iter() - .collect(); - for t in ms { - if reach.entry(n.clone()).or_default().insert(t) { - changed = true; - } - } + let ms: Vec = reach.get(&m).cloned().unwrap_or_default().into_iter().collect(); + for t in ms { if reach.entry(n.clone()).or_default().insert(t) { changed = true; } } } } - if !changed { - break; - } + if !changed { break; } } // find a mutual-reachability pair (cycle) and union it let mut merged = false; @@ -216,9 +183,7 @@ impl Reasoner { } } } - if !merged { - break; - } + if !merged { break; } } // groups let mut groups: BTreeMap> = BTreeMap::new(); @@ -226,22 +191,15 @@ impl Reasoner { let r = find(&mut rep, id); groups.entry(r).or_default().push(id.clone()); } - for m in groups.values_mut() { - m.sort(); - m.dedup(); - } + for m in groups.values_mut() { m.sort(); m.dedup(); } // super-reps (direct), acyclic let mut sup: BTreeMap> = BTreeMap::new(); for c in &onto.classes { let rc = find(&mut rep, &c.id); for p in &c.parents { - if !rep.contains_key(p) { - continue; - } + if !rep.contains_key(p) { continue; } let rp = find(&mut rep, p); - if rc != rp { - sup.entry(rc.clone()).or_default().insert(rp); - } + if rc != rp { sup.entry(rc.clone()).or_default().insert(rp); } } } // raw disjointWith axiom pairs, captured for check_coherence (no Ontology re-access needed) @@ -251,17 +209,10 @@ impl Reasoner { disjoint_pairs.push((c.id.clone(), d.clone())); } } - Reasoner { - rep, - groups, - sup, - disjoint_pairs, - } + Reasoner { rep, groups, sup, disjoint_pairs } } - fn rep_of(&self, id: &str) -> Option { - self.rep.get(id).cloned() - } + fn rep_of(&self, id: &str) -> Option { self.rep.get(id).cloned() } /// reps reachable from `r` (excl. self), transitive. fn closure(&self, r: &str) -> BTreeSet { @@ -277,33 +228,23 @@ impl Reasoner { /// All (proper + improper via equivalents) superclass ids of `class_id`, sorted. pub fn ancestors(&self, class_id: &str) -> Vec { - let Some(r) = self.rep_of(class_id) else { - return Vec::new(); - }; + let Some(r) = self.rep_of(class_id) else { return Vec::new() }; let mut reps = self.closure(&r); reps.insert(r); // scm-cls reflexive let mut out: BTreeSet = BTreeSet::new(); - for rep in reps { - out.extend(self.groups.get(&rep).into_iter().flatten().cloned()); - } + for rep in reps { out.extend(self.groups.get(&rep).into_iter().flatten().cloned()); } out.into_iter().collect() } /// Members equivalent to `class_id` (its group), sorted. pub fn equivalents(&self, class_id: &str) -> Vec { - self.rep_of(class_id) - .and_then(|r| self.groups.get(&r).cloned()) - .unwrap_or_default() + self.rep_of(class_id).and_then(|r| self.groups.get(&r).cloned()).unwrap_or_default() } /// Equivalence groups (size > 1), including both explicit equivalentClass groups /// and subClassOf-cycle folds. Advisory only. pub fn cycle_equivalences(&self) -> Vec> { - self.groups - .values() - .filter(|g| g.len() > 1) - .cloned() - .collect() + self.groups.values().filter(|g| g.len() > 1).cloned().collect() } /// Unsatisfiable classes (cax-dw TBox consequence). Empty = coherent. @@ -317,20 +258,12 @@ impl Reasoner { } let mut out: Vec = Vec::new(); for c_id in self.rep.keys() { - let Some(cr) = self.rep_of(c_id) else { - continue; - }; + let Some(cr) = self.rep_of(c_id) else { continue }; let mut clo = self.closure(&cr); clo.insert(cr); // incl self - // C unsat iff some disjoint pair has BOTH reps in C's closure (covers ra==rb corner) - if let Some((_, _, a, b)) = dis - .iter() - .find(|(ra, rb, _, _)| clo.contains(ra) && clo.contains(rb)) - { - out.push(Issue::UnsatisfiableClass { - class: c_id.clone(), - via_disjoint: (a.clone(), b.clone()), - }); + // C unsat iff some disjoint pair has BOTH reps in C's closure (covers ra==rb corner) + if let Some((_, _, a, b)) = dis.iter().find(|(ra, rb, _, _)| clo.contains(ra) && clo.contains(rb)) { + out.push(Issue::UnsatisfiableClass { class: c_id.clone(), via_disjoint: (a.clone(), b.clone()) }); } } out @@ -338,9 +271,7 @@ impl Reasoner { } impl Ontology { - fn reasoner(&self) -> Reasoner { - Reasoner::build(self) - } + fn reasoner(&self) -> Reasoner { Reasoner::build(self) } /// targetFolder from the nearest ancestor/equivalent (BFS hops; ties by ascending class id). pub fn resolve_target(&self, class_id: &str) -> Option { @@ -358,10 +289,7 @@ impl Ontology { while let Some(u) = q.pop_front() { let d = dist[&u]; for v in r.sup.get(&u).into_iter().flatten() { - if !dist.contains_key(v) { - dist.insert(v.clone(), d + 1); - q.push_back(v.clone()); - } + if !dist.contains_key(v) { dist.insert(v.clone(), d + 1); q.push_back(v.clone()); } } } // candidates: classes with a target whose rep is reachable; pick min (dist, id) @@ -371,13 +299,7 @@ impl Ontology { let Some(cr) = r.rep_of(&c.id) else { continue }; let Some(&d) = dist.get(&cr) else { continue }; let cand = (d, c.id.clone(), t.clone()); - if best - .as_ref() - .map(|b| (cand.0, &cand.1) < (b.0, &b.1)) - .unwrap_or(true) - { - best = Some(cand); - } + if best.as_ref().map(|b| (cand.0, &cand.1) < (b.0, &b.1)).unwrap_or(true) { best = Some(cand); } } best.map(|(_, _, t)| t) } @@ -423,19 +345,11 @@ dm:B a owl:Class ; #[test] fn parses_classes_labels_parents_and_targets() { let onto = parse_ttl(SAMPLE).unwrap(); - let doc = onto - .classes - .iter() - .find(|c| c.id.ends_with("Document")) - .unwrap(); + let doc = onto.classes.iter().find(|c| c.id.ends_with("Document")).unwrap(); assert!(doc.parents.is_empty()); assert_eq!(doc.target_folder.as_deref(), Some("~/Documents/{class}")); assert!(!doc.label.is_empty()); - let rcpt = onto - .classes - .iter() - .find(|c| c.id.ends_with("Receipt")) - .unwrap(); + let rcpt = onto.classes.iter().find(|c| c.id.ends_with("Receipt")).unwrap(); assert!(rcpt.parents.iter().any(|p| p.ends_with("Document"))); assert_eq!(rcpt.target_folder, None); } @@ -453,10 +367,7 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B ; let onto = parse_ttl(ttl).unwrap(); let c = onto.classes.iter().find(|c| c.id.ends_with("#C")).unwrap(); assert_eq!(c.parents.len(), 2); - assert!( - c.parents.iter().any(|p| p.ends_with("#A")) - && c.parents.iter().any(|p| p.ends_with("#B")) - ); + assert!(c.parents.iter().any(|p| p.ends_with("#A")) && c.parents.iter().any(|p| p.ends_with("#B"))); assert!(c.equivalents.iter().any(|e| e.ends_with("#P"))); assert!(c.disjoints.iter().any(|d| d.ends_with("#Q"))); } @@ -473,27 +384,15 @@ dm:A a owl:Class . dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm: "#; let onto = parse_ttl(ttl).unwrap(); let c = onto.classes.iter().find(|c| c.id.ends_with("#C")).unwrap(); - assert_eq!( - c.parents.len(), - 1, - "중복 subClassOf 오브젝트는 한 번만 채택, 리터럴 오브젝트는 무시" - ); + assert_eq!(c.parents.len(), 1, "중복 subClassOf 오브젝트는 한 번만 채택, 리터럴 오브젝트는 무시"); } #[test] fn resolve_target_inherits_from_ancestor() { let onto = parse_ttl(SAMPLE).unwrap(); - let rcpt_id = &onto - .classes - .iter() - .find(|c| c.id.ends_with("Receipt")) - .unwrap() - .id; + let rcpt_id = &onto.classes.iter().find(|c| c.id.ends_with("Receipt")).unwrap().id; // Receipt는 자체 targetFolder 없음 → Document의 것 상속 - assert_eq!( - onto.resolve_target(rcpt_id).as_deref(), - Some("~/Documents/{class}") - ); + assert_eq!(onto.resolve_target(rcpt_id).as_deref(), Some("~/Documents/{class}")); } #[test] @@ -546,63 +445,36 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . let ttl = include_str!("../resources/ontology/default.ttl"); let onto = parse_ttl(ttl).unwrap(); let find = |suffix: &str| { - onto.classes - .iter() - .find(|c| c.id.ends_with(suffix)) - .map(|c| c.id.clone()) + onto.classes.iter().find(|c| c.id.ends_with(suffix)).map(|c| c.id.clone()) }; // Receipt → Document의 폴더 상속 let receipt = find("Receipt").unwrap(); - assert_eq!( - onto.resolve_target(&receipt).as_deref(), - Some("~/Documents/{class}") - ); + assert_eq!(onto.resolve_target(&receipt).as_deref(), Some("~/Documents/{class}")); // Image → Media의 폴더 상속 let image = find("Image").unwrap(); - assert_eq!( - onto.resolve_target(&image).as_deref(), - Some("~/Media/{class}") - ); + assert_eq!(onto.resolve_target(&image).as_deref(), Some("~/Media/{class}")); // Installer는 자체 폴더 let installer = find("Installer").unwrap(); - assert_eq!( - onto.resolve_target(&installer).as_deref(), - Some("~/Installers") - ); + assert_eq!(onto.resolve_target(&installer).as_deref(), Some("~/Installers")); } #[test] fn resolve_target_none_when_parent_chain_cycles() { // targetFolder가 없는 상호 순환 subClassOf — 최대 깊이 방어가 None으로 종료되어야 함 let onto = parse_ttl(CYCLE).unwrap(); - let a_id = &onto - .classes - .iter() - .find(|c| c.id.ends_with('A')) - .unwrap() - .id; + let a_id = &onto.classes.iter().find(|c| c.id.ends_with('A')).unwrap().id; assert_eq!(onto.resolve_target(a_id), None); } - fn onto(ttl: &str) -> Ontology { - parse_ttl(ttl).unwrap() - } + fn onto(ttl: &str) -> Ontology { parse_ttl(ttl).unwrap() } const PRE: &str = "@prefix owl: .\n@prefix rdfs: .\n@prefix dm: .\n"; - fn ends<'a>(v: &'a [String], suf: &str) -> bool { - v.iter().any(|x| x.ends_with(suf)) - } + fn ends<'a>(v: &'a [String], suf: &str) -> bool { v.iter().any(|x| x.ends_with(suf)) } #[test] fn transitive_ancestors_across_multiple_parents() { let o = onto(&format!("{PRE}dm:A a owl:Class ; rdfs:subClassOf dm:B , dm:C .\ndm:B a owl:Class ; rdfs:subClassOf dm:D .\ndm:C a owl:Class .\ndm:D a owl:Class .\n")); let r = Reasoner::build(&o); - let a = o - .classes - .iter() - .find(|c| c.id.ends_with("#A")) - .unwrap() - .id - .clone(); + let a = o.classes.iter().find(|c| c.id.ends_with("#A")).unwrap().id.clone(); let anc = r.ancestors(&a); assert!(ends(&anc, "#B") && ends(&anc, "#C") && ends(&anc, "#D")); } @@ -612,13 +484,7 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . // A ≡ B, B ⊑ C(target) ⇒ A inherits C's folder (scm-eqc1 + scm-sco) let o = onto(&format!("{PRE}dm:A a owl:Class ; owl:equivalentClass dm:B .\ndm:B a owl:Class ; rdfs:subClassOf dm:C .\ndm:C a owl:Class ; dm:targetFolder \"~/C\" .\n")); let r = Reasoner::build(&o); - let a = o - .classes - .iter() - .find(|c| c.id.ends_with("#A")) - .unwrap() - .id - .clone(); + let a = o.classes.iter().find(|c| c.id.ends_with("#A")).unwrap().id.clone(); assert!(ends(&r.equivalents(&a), "#B")); assert!(ends(&r.ancestors(&a), "#C")); assert_eq!(o.resolve_target(&a).as_deref(), Some("~/C")); @@ -629,13 +495,7 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . // scm-eqc2: A ⊑ B ⊑ A ⇒ equivalent, coherent, resolve terminates let o = onto(&format!("{PRE}dm:A a owl:Class ; rdfs:subClassOf dm:B .\ndm:B a owl:Class ; rdfs:subClassOf dm:A .\n")); let r = Reasoner::build(&o); - let a = o - .classes - .iter() - .find(|c| c.id.ends_with("#A")) - .unwrap() - .id - .clone(); + let a = o.classes.iter().find(|c| c.id.ends_with("#A")).unwrap().id.clone(); assert!(ends(&r.equivalents(&a), "#B")); assert!(r.check_coherence().is_empty()); assert_eq!(o.resolve_target(&a), None); @@ -646,13 +506,7 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . // A ≡ B, B ⊑ C, C ⊑ A ⇒ {A,B,C} equivalent (merge exposes 2nd-round SCC) let o = onto(&format!("{PRE}dm:A a owl:Class ; owl:equivalentClass dm:B .\ndm:B a owl:Class ; rdfs:subClassOf dm:C .\ndm:C a owl:Class ; rdfs:subClassOf dm:A .\n")); let r = Reasoner::build(&o); - let a = o - .classes - .iter() - .find(|c| c.id.ends_with("#A")) - .unwrap() - .id - .clone(); + let a = o.classes.iter().find(|c| c.id.ends_with("#A")).unwrap().id.clone(); let eq = r.equivalents(&a); assert!(ends(&eq, "#B") && ends(&eq, "#C")); assert!(r.check_coherence().is_empty()); @@ -664,9 +518,7 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . let o = onto(&format!("{PRE}dm:A a owl:Class ; owl:disjointWith dm:B .\ndm:B a owl:Class .\ndm:C a owl:Class ; rdfs:subClassOf dm:A , dm:B .\n")); let r = Reasoner::build(&o); let issues = r.check_coherence(); - assert!(issues.iter().any( - |i| matches!(i, Issue::UnsatisfiableClass { class, .. } if class.ends_with("#C")) - )); + assert!(issues.iter().any(|i| matches!(i, Issue::UnsatisfiableClass { class, .. } if class.ends_with("#C")))); } #[test] @@ -675,12 +527,8 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . let o = onto(&format!("{PRE}dm:A a owl:Class ; owl:equivalentClass dm:B ; owl:disjointWith dm:B .\ndm:B a owl:Class .\ndm:D a owl:Class ; owl:disjointWith dm:D .\n")); let r = Reasoner::build(&o); let issues = r.check_coherence(); - assert!(issues.iter().any( - |i| matches!(i, Issue::UnsatisfiableClass { class, .. } if class.ends_with("#A")) - )); - assert!(issues.iter().any( - |i| matches!(i, Issue::UnsatisfiableClass { class, .. } if class.ends_with("#D")) - )); + assert!(issues.iter().any(|i| matches!(i, Issue::UnsatisfiableClass { class, .. } if class.ends_with("#A")))); + assert!(issues.iter().any(|i| matches!(i, Issue::UnsatisfiableClass { class, .. } if class.ends_with("#D")))); } #[test] @@ -693,13 +541,7 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . fn resolve_target_nearest_first_id_tiebreak() { // C ⊑ A(~/A), C ⊑ B(~/B): both distance-1 ⇒ id-tiebreak picks A let o = onto(&format!("{PRE}dm:A a owl:Class ; dm:targetFolder \"~/A\" .\ndm:B a owl:Class ; dm:targetFolder \"~/B\" .\ndm:C a owl:Class ; rdfs:subClassOf dm:A , dm:B .\n")); - let c = o - .classes - .iter() - .find(|c| c.id.ends_with("#C")) - .unwrap() - .id - .clone(); + let c = o.classes.iter().find(|c| c.id.ends_with("#C")).unwrap().id.clone(); assert_eq!(o.resolve_target(&c).as_deref(), Some("~/A")); } @@ -707,10 +549,7 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . fn cycle_equivalences_reports_merged_groups() { let o = onto(&format!("{PRE}dm:A a owl:Class ; rdfs:subClassOf dm:B .\ndm:B a owl:Class ; rdfs:subClassOf dm:A .\ndm:X a owl:Class .\n")); let r = Reasoner::build(&o); - assert!(r - .cycle_equivalences() - .iter() - .any(|g| g.len() == 2 && ends(g, "#A") && ends(g, "#B"))); + assert!(r.cycle_equivalences().iter().any(|g| g.len() == 2 && ends(g, "#A") && ends(g, "#B"))); } #[test] @@ -718,13 +557,7 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . // subClassOf / equivalentClass / disjointWith → never-declared classes: must be skipped, no panic (spec §7) let o = onto(&format!("{PRE}dm:A a owl:Class ; rdfs:subClassOf dm:Ghost ; owl:equivalentClass dm:Phantom ; owl:disjointWith dm:Specter .\n")); let r = Reasoner::build(&o); - let a = o - .classes - .iter() - .find(|c| c.id.ends_with("#A")) - .unwrap() - .id - .clone(); + let a = o.classes.iter().find(|c| c.id.ends_with("#A")).unwrap().id.clone(); // A has no known supers/equivalents beyond itself; nothing panics; ontology is coherent assert!(r.ancestors(&a).iter().any(|x| x.ends_with("#A"))); // scm-cls self assert!(r.check_coherence().is_empty()); @@ -739,13 +572,7 @@ dm:C a owl:Class ; rdfs:subClassOf dm:A ; rdfs:subClassOf dm:B . // check that redundant declarations don't fragment or duplicate the equivalence group. let o = onto(&format!("{PRE}dm:A a owl:Class ; owl:equivalentClass dm:B .\ndm:B a owl:Class ; owl:equivalentClass dm:A .\n")); let r = Reasoner::build(&o); - let a = o - .classes - .iter() - .find(|c| c.id.ends_with("#A")) - .unwrap() - .id - .clone(); + let a = o.classes.iter().find(|c| c.id.ends_with("#A")).unwrap().id.clone(); let eq = r.equivalents(&a); assert_eq!(eq.len(), 2); assert!(ends(&eq, "#A") && ends(&eq, "#B")); diff --git a/src-tauri/src/organize.rs b/src-tauri/src/organize.rs index 5ce4cba8f..78abe649f 100644 --- a/src-tauri/src/organize.rs +++ b/src-tauri/src/organize.rs @@ -37,29 +37,22 @@ pub fn plan_moves_with( for f in files { // filename을 classify보다 먼저 확인 — 파일명 없는 경로(루트 등)는 여기서 걸러진다. // (classify 뒤에 두면 이 분기가 도달 불가라 커버리지 사각이 됨) - let Some(name) = f.path.file_name() else { - continue; - }; + let Some(name) = f.path.file_name() else { continue }; let age_days = now_ms.saturating_sub(f.mtime_ms) / 86_400_000; // precedence: 사용자 규칙 → picker(LLM) → 확장자 classify → 제외 - let local: String = - match crate::userrules::classify_by_rules(rules, &f.path, f.size, age_days) { - Some(c) => c, - None => match pick(&f.path, &candidates) { - Some(picked) => picked, - None => match classify(&f.path) { - Some(c) => c.to_string(), - None => continue, - }, + let local: String = match crate::userrules::classify_by_rules(rules, &f.path, f.size, age_days) { + Some(c) => c, + None => match pick(&f.path, &candidates) { + Some(picked) => picked, + None => match classify(&f.path) { + Some(c) => c.to_string(), + None => continue, }, - }; - // 로컬명 → 온톨로지 클래스 - let Some(class) = onto.classes.iter().find(|c| local_name(&c.id) == local) else { - continue; - }; - let Some(template) = onto.resolve_target_with(&reasoner, &class.id) else { - continue; + }, }; + // 로컬명 → 온톨로지 클래스 + let Some(class) = onto.classes.iter().find(|c| local_name(&c.id) == local) else { continue }; + let Some(template) = onto.resolve_target_with(&reasoner, &class.id) else { continue }; // 템플릿 치환: ~ → home, {class} → 로컬명 let folder = template .replacen('~', &home.to_string_lossy(), 1) @@ -99,19 +92,11 @@ dm:Installer a owl:Class ; rdfs:label "설치파일"@ko ; dm:targetFolder "~/Ins "#; fn fe(p: &str, size: u64) -> FileEntry { - FileEntry { - path: PathBuf::from(p), - size, - mtime_ms: 0, - } + 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, - } + FileEntry { path: PathBuf::from(p), size, mtime_ms } } #[test] @@ -133,8 +118,8 @@ dm:Installer a owl:Class ; rdfs:label "설치파일"@ko ; dm:targetFolder "~/Ins 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 없음 → 제외 + fe("/x/unknown.xyz", 10), // 미분류 → 제외 + fe("/x/main.rs", 20), // Code: targetFolder 없음 → 제외 ]; assert!(plan_moves(&files, &onto, home).is_empty()); } @@ -242,23 +227,15 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ let onto = parse_ttl(ONTO).unwrap(); let home = Path::new("/home/u"); let rules = vec![crate::userrules::Rule { - r#match: crate::userrules::RuleMatch { - ext: Some("png".into()), - name_contains: None, - path_contains: None, - min_size: None, - max_size: None, - min_age_days: None, - max_age_days: None, - }, + r#match: crate::userrules::RuleMatch { ext: Some("png".into()), name_contains: None, path_contains: None, min_size: None, max_size: None, min_age_days: None, max_age_days: None }, 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); assert_eq!(plans.len(), 1); assert!(plans[0].class_id.ends_with("Installer")); // 규칙이 picker를 이긴다 - // 규칙이 우선하므로 plan_moves_with 내부에서 pick은 호출되지 않는다(설계상 의도). - // 라인 커버리지 확보를 위해 클로저 자체가 유효한 picker임을 별도로 확인. + // 규칙이 우선하므로 plan_moves_with 내부에서 pick은 호출되지 않는다(설계상 의도). + // 라인 커버리지 확보를 위해 클로저 자체가 유효한 picker임을 별도로 확인. assert_eq!(pick(Path::new("/x"), &[]), Some("Image".to_string())); } @@ -268,15 +245,7 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ let onto = parse_ttl(ONTO).unwrap(); let home = Path::new("/home/u"); let rules = vec![crate::userrules::Rule { - r#match: crate::userrules::RuleMatch { - ext: Some("iso".into()), - name_contains: None, - path_contains: None, - min_size: None, - max_size: None, - min_age_days: None, - max_age_days: None, - }, + r#match: crate::userrules::RuleMatch { ext: Some("iso".into()), name_contains: None, path_contains: None, min_size: None, max_size: None, min_age_days: None, max_age_days: None }, class: "Installer".into(), }]; let pick = |_p: &Path, _c: &[&str]| None; @@ -292,38 +261,16 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ let home = Path::new("/home/u"); let now = 100 * 86_400_000u64; let rules = vec![crate::userrules::Rule { - r#match: crate::userrules::RuleMatch { - ext: None, - name_contains: None, - path_contains: None, - min_size: None, - max_size: None, - min_age_days: Some(30), - max_age_days: None, - }, + r#match: crate::userrules::RuleMatch { ext: None, name_contains: None, path_contains: None, min_size: None, max_size: None, min_age_days: Some(30), max_age_days: None }, class: "Installer".into(), }]; 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(&[fe_at("/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(&[fe_at("/d/pic.png", 10, now)], &onto, home, now, &rules, &pick); assert_eq!(fresh.len(), 1); assert!(fresh[0].class_id.ends_with("Image")); } @@ -337,26 +284,11 @@ dm:Image a owl:Class ; rdfs:label "이미지"@ko ; dm:targetFolder "/opt/media/{ let now = 100 * 86_400_000u64; let future = 200 * 86_400_000u64; // mtime in the future relative to now let rules = vec![crate::userrules::Rule { - r#match: crate::userrules::RuleMatch { - ext: None, - name_contains: None, - path_contains: None, - min_size: None, - max_size: None, - min_age_days: Some(1), - max_age_days: None, - }, + r#match: crate::userrules::RuleMatch { ext: None, name_contains: None, path_contains: None, min_size: None, max_size: None, min_age_days: Some(1), max_age_days: None }, 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(&[fe_at("/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/reasoning.rs b/src-tauri/src/reasoning.rs index 3eed683a2..28dbb02ce 100644 --- a/src-tauri/src/reasoning.rs +++ b/src-tauri/src/reasoning.rs @@ -13,12 +13,7 @@ pub struct ExtInsight { pub fn distinct_extensions(samples: &[String]) -> Vec { let mut exts: Vec = samples .iter() - .filter_map(|p| { - std::path::Path::new(p) - .extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - }) + .filter_map(|p| std::path::Path::new(p).extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase())) .collect(); exts.sort(); exts.dedup(); @@ -29,11 +24,7 @@ pub fn distinct_extensions(samples: &[String]) -> Vec { pub fn merge_insight(ext: &str, llm: Option, web: Option) -> ExtInsight { let (llm_type, suggested_class) = match &llm { Some(r) => ( - if r.type_desc.is_empty() { - None - } else { - Some(r.type_desc.clone()) - }, + if r.type_desc.is_empty() { None } else { Some(r.type_desc.clone()) }, r.class.clone(), ), None => (None, None), @@ -43,15 +34,9 @@ pub fn merge_insight(ext: &str, llm: Option, web: Option) (false, true) => "web", (true, false) => "llm", (false, false) => "none", - } - .to_string(); + }.to_string(); let type_desc = web.or(llm_type); // 웹 우선 - ExtInsight { - ext: ext.to_string(), - type_desc, - suggested_class, - source, - } + ExtInsight { ext: ext.to_string(), type_desc, suggested_class, source } } /// 확장자별 오프라인 추론 + (online일 때만) 웹 조회 병합. web=None이면 웹 분기 절대 미실행(default offline). @@ -75,23 +60,12 @@ mod tests { #[test] fn distinct_extensions_lowercased_sorted_deduped() { - let s = vec![ - "/a/x.FBX".into(), - "/b/y.fbx".into(), - "/c/z.parquet".into(), - "/d/noext".into(), - ]; - assert_eq!( - distinct_extensions(&s), - vec!["fbx".to_string(), "parquet".to_string()] - ); + let s = vec!["/a/x.FBX".into(), "/b/y.fbx".into(), "/c/z.parquet".into(), "/d/noext".into()]; + assert_eq!(distinct_extensions(&s), vec!["fbx".to_string(), "parquet".to_string()]); } #[test] fn merge_prefers_web_type_keeps_llm_class() { - let llm = Some(ExtReasoning { - type_desc: "3D model".into(), - class: Some("Model3D".into()), - }); + let llm = Some(ExtReasoning { type_desc: "3D model".into(), class: Some("Model3D".into()) }); let ins = merge_insight("fbx", llm, Some("Autodesk FBX 3D format".into())); assert_eq!(ins.type_desc.as_deref(), Some("Autodesk FBX 3D format")); // 웹 우선 assert_eq!(ins.suggested_class.as_deref(), Some("Model3D")); @@ -99,15 +73,9 @@ mod tests { } #[test] fn merge_llm_only_and_web_only_and_none() { - let llm = Some(ExtReasoning { - type_desc: "data".into(), - class: None, - }); + let llm = Some(ExtReasoning { type_desc: "data".into(), class: None }); assert_eq!(merge_insight("dat", llm, None).source, "llm"); - assert_eq!( - merge_insight("dat", None, Some("desc".into())).source, - "web" - ); + assert_eq!(merge_insight("dat", None, Some("desc".into())).source, "web"); let none = merge_insight("dat", None, None); assert_eq!(none.source, "none"); assert_eq!(none.type_desc, None); @@ -115,12 +83,7 @@ mod tests { #[test] fn build_insights_offline_never_calls_web() { // web=None → 웹 클로저가 없으므로 호출 자체가 불가능(프라이버시: default offline) - let reason = |e: &str| { - Some(ExtReasoning { - type_desc: format!("t-{e}"), - class: None, - }) - }; + let reason = |e: &str| Some(ExtReasoning { type_desc: format!("t-{e}"), class: None }); let out = build_insights(&["fbx".into()], &reason, None); assert_eq!(out[0].source, "llm"); assert_eq!(out[0].type_desc.as_deref(), Some("t-fbx")); @@ -128,10 +91,7 @@ mod tests { #[test] fn merge_llm_with_empty_type_desc_yields_no_type_but_keeps_class() { // LLM이 type을 "none"으로 답해 빈 문자열로 파싱된 경우 — type_desc는 None, class 제안은 유지 - let llm = Some(ExtReasoning { - type_desc: "".into(), - class: Some("Model3D".into()), - }); + let llm = Some(ExtReasoning { type_desc: "".into(), class: Some("Model3D".into()) }); let ins = merge_insight("fbx", llm, None); assert_eq!(ins.type_desc, None); assert_eq!(ins.suggested_class.as_deref(), Some("Model3D")); @@ -141,10 +101,7 @@ mod tests { fn build_insights_online_receives_only_ext_token() { // 프라이버시: 웹 클로저에 넘어오는 값은 확장자 토큰뿐(경로 구분자 없음) let reason = |_: &str| None; - let web = |e: &str| { - assert!(!e.contains('/') && !e.contains('.')); - Some(format!("web-{e}")) - }; + let web = |e: &str| { assert!(!e.contains('/') && !e.contains('.')); Some(format!("web-{e}")) }; let out = build_insights(&["parquet".into()], &reason, Some(&web)); assert_eq!(out[0].source, "web"); assert_eq!(out[0].type_desc.as_deref(), Some("web-parquet")); diff --git a/src-tauri/src/rules.rs b/src-tauri/src/rules.rs index 3be2d448c..2fa6cebbf 100644 --- a/src-tauri/src/rules.rs +++ b/src-tauri/src/rules.rs @@ -21,11 +21,7 @@ impl BaseDirs { let local_data = std::env::var("LOCALAPPDATA").map(PathBuf::from).ok()?; #[cfg(not(windows))] let local_data = home.join(".cache"); - Some(BaseDirs { - temp, - local_data, - home, - }) + Some(BaseDirs { temp, local_data, home }) } } @@ -62,11 +58,8 @@ fn catalog(bases: &BaseDirs) -> Vec<(&'static str, &'static str, PathBuf)> { ("os-temp", "OS 임시 폴더", bases.temp.clone()), ("npm-cache", "npm 캐시", npm), ("pip-cache", "pip 캐시", pip), - ( - "cargo-registry-cache", - "cargo 레지스트리 캐시", - bases.home.join(".cargo").join("registry").join("cache"), - ), + ("cargo-registry-cache", "cargo 레지스트리 캐시", + bases.home.join(".cargo").join("registry").join("cache")), ]; // Windows 진단 캐시 — 조용히 수십 GB로 자라는 것들. RDP 자동 추적(RdClientAutoTrace)의 .etl 로그가 @@ -74,25 +67,12 @@ fn catalog(bases: &BaseDirs) -> Vec<(&'static str, &'static str, PathBuf)> { // 사용자가 크기를 보고 그것만 콕 집어 정리하게 한다. WER/CrashDumps도 동류의 진단 산출물. #[cfg(windows)] entries.extend([ - ( - "rdp-autotrace", - "원격 데스크톱 추적 로그", - bases.temp.join("DiagOutputDir").join("RdClientAutoTrace"), - ), - ( - "windows-crashdumps", - "앱 크래시 덤프", - bases.local_data.join("CrashDumps"), - ), - ( - "windows-wer", - "Windows 오류 보고 (WER)", - bases - .local_data - .join("Microsoft") - .join("Windows") - .join("WER"), - ), + ("rdp-autotrace", "원격 데스크톱 추적 로그", + bases.temp.join("DiagOutputDir").join("RdClientAutoTrace")), + ("windows-crashdumps", "앱 크래시 덤프", + bases.local_data.join("CrashDumps")), + ("windows-wer", "Windows 오류 보고 (WER)", + bases.local_data.join("Microsoft").join("Windows").join("WER")), ]); entries @@ -108,9 +88,7 @@ pub fn cache_candidates(bases: &BaseDirs) -> Vec { // UX가 문제 되면 candidates에 취소 토큰과 진행 이벤트를 추가. // interval 1: 진행 콜백(no-op)이 작은 테스트 픽스처에서도 실행되어 커버리지에서 // 0으로 남지 않음 — 콜백이 아무 일도 하지 않으므로 호출 빈도는 동작에 무관 - scanner::scan_dir_with_interval(&path, &AtomicBool::new(false), 1, |_| {}) - .stats - .bytes + scanner::scan_dir_with_interval(&path, &AtomicBool::new(false), 1, |_| {}).stats.bytes } else { 0 }; @@ -133,9 +111,7 @@ pub fn is_catalog_path(bases: &BaseDirs, dir: &Path) -> bool { /// 캐시 디렉토리 자체는 보존하고 내용물만 비우기 위한 직계 자식 열거. /// 심링크는 제외 — 이 코드베이스의 모든 순회와 동일한 방어 (scanner keep_entry, node_view 참조) pub fn clean_targets(dir: &Path) -> Vec { - let Ok(rd) = std::fs::read_dir(dir) else { - return Vec::new(); - }; + let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() }; rd.filter_map(|e| e.ok()) .filter(|e| e.file_type().map(|t| !t.is_symlink()).unwrap_or(false)) .map(|e| e.path()) @@ -168,11 +144,7 @@ mod tests { let bases = fake_bases(tmp.path()); // npm 캐시만 실제로 만들어 둔다 (한 줄: 각 arm이 별도 라인이면 플랫폼별로 반대쪽이 // 영구 미커버로 남는다 — is_protected의 home 변수명 선택과 동일한 관례) - let npm = if cfg!(windows) { - bases.local_data.join("npm-cache") - } else { - bases.home.join(".npm") - }; + let npm = if cfg!(windows) { bases.local_data.join("npm-cache") } else { bases.home.join(".npm") }; fs::create_dir_all(&npm).unwrap(); fs::write(npm.join("blob.bin"), vec![0u8; 128]).unwrap(); @@ -237,8 +209,7 @@ mod tests { fn clean_targets_excludes_symlinks() { let tmp = tempfile::tempdir().unwrap(); fs::write(tmp.path().join("real.bin"), b"x").unwrap(); - std::os::unix::fs::symlink(tmp.path().join("real.bin"), tmp.path().join("link.bin")) - .unwrap(); + std::os::unix::fs::symlink(tmp.path().join("real.bin"), tmp.path().join("link.bin")).unwrap(); let names: Vec = clean_targets(tmp.path()) .iter() .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) diff --git a/src-tauri/src/safety.rs b/src-tauri/src/safety.rs index dd6f841e0..1a581b504 100644 --- a/src-tauri/src/safety.rs +++ b/src-tauri/src/safety.rs @@ -99,18 +99,11 @@ pub fn is_protected(path: &Path) -> bool { // macOS는 extend로 시스템 경로를 더 넣는다 — 다른 unix에선 그 라인이 cfg-out되어 mut가 // 미사용이므로 allow(unused_mut). Linux 게이트는 macOS 전용 라인을 컴파일하지 않아 커버 불필요. #[allow(unused_mut)] - let mut denied_prefixes: Vec<&str> = vec![ - "/usr", "/etc", "/bin", "/sbin", "/lib", "/boot", "/proc", "/sys", "/dev", - ]; + let mut denied_prefixes: Vec<&str> = + vec!["/usr", "/etc", "/bin", "/sbin", "/lib", "/boot", "/proc", "/sys", "/dev"]; #[cfg(target_os = "macos")] denied_prefixes.extend_from_slice(&[ - "/System", - "/Library", - "/Applications", - "/private", - "/Volumes", - "/cores", - "/Network", + "/System", "/Library", "/Applications", "/private", "/Volumes", "/cores", "/Network", ]); let s = path.to_string_lossy(); if denied_prefixes @@ -171,9 +164,7 @@ pub fn journal_append(journal_path: &Path, entry: &JournalEntry) -> Result<(), S } pub fn journal_recent(journal_path: &Path, limit: usize) -> Vec { - let Ok(content) = std::fs::read_to_string(journal_path) else { - return Vec::new(); - }; + let Ok(content) = std::fs::read_to_string(journal_path) else { return Vec::new() }; let mut entries: Vec = content .lines() .filter_map(|l| serde_json::from_str(l).ok()) @@ -189,9 +180,7 @@ pub fn journal_recent(journal_path: &Path, limit: usize) -> Vec { fn strip_verbatim(p: &Path) -> PathBuf { use std::path::{Component, Prefix}; let mut comps = p.components(); - let Some(Component::Prefix(pr)) = comps.next() else { - return p.to_path_buf(); - }; + let Some(Component::Prefix(pr)) = comps.next() else { return p.to_path_buf() }; match pr.kind() { Prefix::VerbatimDisk(d) => { let mut out = PathBuf::from(format!("{}:\\", d as char)); @@ -252,16 +241,12 @@ pub fn trash_delete( now_ms: u64, ) -> Result<(), SafetyError> { // '..'는 lexical 가드를 우회해 보호 경로 밖으로 보이게 할 수 있음 — 컴포넌트 단위로 먼저 거부 - if path - .components() - .any(|c| matches!(c, std::path::Component::ParentDir)) - { + if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) { return Err(SafetyError::Protected(path.to_path_buf())); } // 가드는 정규화된 경로로 판정. canonicalize 실패(예: 이미 사라진 경로)면 // lexical 경로로 판정한다 (ParentDir는 위에서 이미 거부됨) — 어느 쪽이든 verbatim은 재구성. - let guard_path = - strip_verbatim(&std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())); + let guard_path = strip_verbatim(&std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())); if is_protected(&guard_path) { return Err(SafetyError::Protected(path.to_path_buf())); } @@ -295,9 +280,7 @@ pub fn same_volume(src: &Path, dst: &Path) -> bool { { fn drive(p: &Path) -> Option { p.components().next().and_then(|c| match c { - std::path::Component::Prefix(pr) => { - Some(pr.as_os_str().to_string_lossy().to_lowercase()) - } + std::path::Component::Prefix(pr) => Some(pr.as_os_str().to_string_lossy().to_lowercase()), _ => None, }) } @@ -327,10 +310,7 @@ fn copy_then_hash( ) -> std::io::Result<(u64, u64, Result, Result)> { { let mut src_file = std::fs::File::open(src)?; - let mut dst_file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(dst)?; + let mut dst_file = std::fs::OpenOptions::new().write(true).create_new(true).open(dst)?; std::io::copy(&mut src_file, &mut dst_file)?; // 핸들을 여기서 닫아 이후 metadata/hash_full이 경로로 다시 읽을 때 걸리지 않게 함 } @@ -360,10 +340,7 @@ fn finalize_verified_copy(dst: &Path, verified: bool) -> std::io::Result<()> { Ok(()) } else { let _ = std::fs::remove_file(dst); // 우리가 만든 목적지이므로 정리 - Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "복사 검증 실패", - )) + Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "복사 검증 실패")) } } @@ -385,10 +362,7 @@ fn preserve_source_metadata(src: &Path, dst: &Path) -> std::io::Result<()> { if let Ok(accessed) = src_md.accessed() { times = times.set_accessed(accessed); } - std::fs::OpenOptions::new() - .write(true) - .open(dst)? - .set_times(times)?; + std::fs::OpenOptions::new().write(true).open(dst)?.set_times(times)?; // 권한은 **마지막**에 복원한다(원본이 읽기 전용이어도 위 set_times가 이미 끝난 뒤라 안전). // set_permissions는 mtime이 아니라 ctime만 바꾸므로 방금 설정한 mtime을 훼손하지 않는다. std::fs::set_permissions(dst, src_md.permissions())?; @@ -482,9 +456,7 @@ pub fn move_file( ) -> Result<(), SafetyError> { // 보호: src·dst 양쪽, ParentDir 거부, verbatim 정규화 — trash_delete와 동일 리거 for p in [src, dst] { - if p.components() - .any(|c| matches!(c, std::path::Component::ParentDir)) - { + if p.components().any(|c| matches!(c, std::path::Component::ParentDir)) { return Err(SafetyError::Protected(p.to_path_buf())); } let guard = normalize_for_guard(p); @@ -494,10 +466,7 @@ pub fn move_file( } // 목적지 충돌 금지 (덮어쓰기 방지) if dst.exists() { - return Err(SafetyError::Trash(format!( - "목적지가 이미 존재: {}", - dst.display() - ))); + return Err(SafetyError::Trash(format!("목적지가 이미 존재: {}", dst.display()))); } // 목적지 부모 디렉토리 생성. 위 protected 검사가 parent 없는 경로를 이미 거부했으므로 // parent는 항상 Some — 폴백(dst 자신)은 실제로 도달 불가지만, 패닉(expect) 대신 한 줄 @@ -554,15 +523,9 @@ mod tests { #[test] fn safety_error_display_messages() { - 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!(SafetyError::Protected(PathBuf::from("/x")).to_string().contains("보호")); + assert!(SafetyError::Trash("boom".into()).to_string().contains("휴지통")); + assert!(SafetyError::Journal("boom".into()).to_string().contains("저널")); } #[test] @@ -574,11 +537,7 @@ mod tests { #[test] fn protects_home_root_but_not_home_children() { // 한 줄: 각 arm이 별도 라인이면 플랫폼별로 반대쪽이 영구 미커버로 남는다 - let home = if cfg!(windows) { - std::env::var("USERPROFILE").unwrap() - } else { - std::env::var("HOME").unwrap() - }; + let home = if cfg!(windows) { std::env::var("USERPROFILE").unwrap() } else { std::env::var("HOME").unwrap() }; assert!(is_protected(Path::new(&home))); assert!(!is_protected(&Path::new(&home).join("some-cache-dir"))); } @@ -595,9 +554,7 @@ mod tests { // 현재 머신의 실제 SystemRoot는 반드시 보호됨 (C:든 다른 드라이브든) let sysroot = std::env::var("SystemRoot").unwrap(); assert!(is_protected(std::path::Path::new(&sysroot))); - assert!(is_protected( - &std::path::Path::new(&sysroot).join("System32") - )); + assert!(is_protected(&std::path::Path::new(&sysroot).join("System32"))); } #[cfg(windows)] @@ -675,10 +632,7 @@ mod tests { let root = if cfg!(windows) { "C:\\Windows" } else { "/usr" }; let err = trash_delete(Path::new(root), 0, &jp, 1); assert!(matches!(err, Err(SafetyError::Protected(_)))); - assert!( - journal_recent(&jp, 10).is_empty(), - "보호 거부는 저널 이전에 일어나야 함" - ); + assert!(journal_recent(&jp, 10).is_empty(), "보호 거부는 저널 이전에 일어나야 함"); } #[test] @@ -714,11 +668,7 @@ mod tests { let items: Vec<_> = trash::os_limited::list() .unwrap() .into_iter() - .filter(|i| { - i.name - .to_string_lossy() - .contains("disksage-roundtrip-fixture") - }) + .filter(|i| i.name.to_string_lossy().contains("disksage-roundtrip-fixture")) .collect(); assert!(!items.is_empty(), "휴지통에 있어야 함"); trash::os_limited::purge_all(items).unwrap(); @@ -756,18 +706,10 @@ mod tests { strip_verbatim(Path::new(r"\\?\UNC\srv\share\dir")), Path::new(r"\\srv\share\dir") ); - assert_eq!( - strip_verbatim(Path::new(r"C:\plain")), - Path::new(r"C:\plain") - ); - assert_eq!( - strip_verbatim(Path::new("relative/only")), - Path::new("relative/only") - ); + assert_eq!(strip_verbatim(Path::new(r"C:\plain")), Path::new(r"C:\plain")); + assert_eq!(strip_verbatim(Path::new("relative/only")), Path::new("relative/only")); // 재구성된 UNC 공유 루트는 parent가 없어 보호된다 (fail-closed 확인) - assert!(is_protected(&strip_verbatim(Path::new( - r"\\?\UNC\srv\share" - )))); + assert!(is_protected(&strip_verbatim(Path::new(r"\\?\UNC\srv\share")))); } #[test] @@ -798,26 +740,12 @@ mod tests { let jp = tmp.path().join("j.jsonl"); let f = tmp.path().join("f.bin"); std::fs::write(&f, b"x").unwrap(); - let protected = std::path::PathBuf::from(if cfg!(windows) { - "C:\\Windows\\x" - } else { - "/usr/x" - }); + let protected = std::path::PathBuf::from(if cfg!(windows) { "C:\\Windows\\x" } else { "/usr/x" }); // 보호된 목적지 - assert!(matches!( - move_file(&f, &protected, &jp, 1), - Err(SafetyError::Protected(_)) - )); + assert!(matches!(move_file(&f, &protected, &jp, 1), Err(SafetyError::Protected(_)))); // 보호된 출발 - let pf = std::path::PathBuf::from(if cfg!(windows) { - "C:\\Windows\\y" - } else { - "/usr/y" - }); - assert!(matches!( - move_file(&pf, &tmp.path().join("z"), &jp, 1), - Err(SafetyError::Protected(_)) - )); + let pf = std::path::PathBuf::from(if cfg!(windows) { "C:\\Windows\\y" } else { "/usr/y" }); + assert!(matches!(move_file(&pf, &tmp.path().join("z"), &jp, 1), Err(SafetyError::Protected(_)))); assert!(journal_recent(&jp, 10).is_empty(), "보호 거부는 저널 이전"); } @@ -838,10 +766,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let missing = tmp.path().join("nested").join("does-not-exist.bin"); let expected_base = strip_verbatim(&std::fs::canonicalize(tmp.path()).unwrap()); - assert_eq!( - normalize_for_guard(&missing), - expected_base.join("nested").join("does-not-exist.bin") - ); + assert_eq!(normalize_for_guard(&missing), expected_base.join("nested").join("does-not-exist.bin")); } // Fix 2 회귀 테스트: 슬래시 없는 단일 상대 컴포넌트는 조상이 ""까지 내려가고 canonicalize("")도 @@ -917,11 +842,8 @@ mod tests { assert!(!src.exists(), "원본은 휴지통으로"); assert_eq!(std::fs::read(&dst).unwrap().len(), 40); // 원본이 휴지통에 있음 확인 후 테스트 픽스처만 purge - let items: Vec<_> = trash::os_limited::list() - .unwrap() - .into_iter() - .filter(|i| i.name.to_string_lossy().contains("disksage-xvol-fixture")) - .collect(); + let items: Vec<_> = trash::os_limited::list().unwrap().into_iter() + .filter(|i| i.name.to_string_lossy().contains("disksage-xvol-fixture")).collect(); trash::os_limited::purge_all(items).unwrap(); } @@ -1056,10 +978,7 @@ mod tests { let err = move_file(&src, &dst, &jp, 1); assert!(matches!(err, Err(SafetyError::Trash(_)))); assert!(src.exists(), "부모 생성 실패 시 원본 보존"); - assert!( - journal_recent(&jp, 10).is_empty(), - "부모 생성 실패는 저널 이전에 실패" - ); + assert!(journal_recent(&jp, 10).is_empty(), "부모 생성 실패는 저널 이전에 실패"); } #[test] @@ -1108,10 +1027,7 @@ mod tests { let err = || Err::("read failed".into()); assert!(!hashes_match(&err(), &ok(), 10, 10)); assert!(!hashes_match(&ok(), &err(), 10, 10)); - assert!( - !hashes_match(&err(), &err(), 10, 10), - "양쪽 다 실패해도 절대 일치로 읽히면 안 됨" - ); + assert!(!hashes_match(&err(), &err(), 10, 10), "양쪽 다 실패해도 절대 일치로 읽히면 안 됨"); } #[test] diff --git a/src-tauri/src/scanner.rs b/src-tauri/src/scanner.rs index 5c995ed31..6e1f6d285 100644 --- a/src-tauri/src/scanner.rs +++ b/src-tauri/src/scanner.rs @@ -60,10 +60,7 @@ pub fn scan_dir_with_interval( seen += 1; // 순회/메타데이터 오류는 skipped로 집계 — 한 줄 let-else (오류 분기가 플랫폼별 테스트에만 // 잡히더라도 라인 자체는 항상 실행돼 커버리지가 안정적) - let Ok(e) = entry else { - stats.skipped += 1; - continue; - }; + let Ok(e) = entry else { stats.skipped += 1; continue }; if e.file_type().is_dir() { stats.dirs += 1; // jwalk는 하위 목록 읽기 실패를 Err 항목이 아니라 디렉토리 엔트리의 @@ -73,10 +70,7 @@ pub fn scan_dir_with_interval( } dir_sizes.entry(e.path()).or_insert(0); } else if e.file_type().is_file() { - let Ok(md) = e.metadata() else { - stats.skipped += 1; - continue; - }; + let Ok(md) = e.metadata() else { stats.skipped += 1; continue }; let size = md.len(); stats.files += 1; stats.bytes += size; @@ -284,9 +278,7 @@ mod tests { fn unreadable_dir_counts_as_skipped() { use std::os::unix::fs::PermissionsExt; // root는 권한 비트를 무시하므로 이 테스트는 의미 없음 (한 줄: CI 비-root에서 return 라인 미실행 방지) - if running_as_root() { - return; - } + if running_as_root() { return; } let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let locked = root.join("locked"); @@ -297,11 +289,7 @@ mod tests { let res = scan_dir(root, &AtomicBool::new(false), noop); fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap(); - assert!( - res.stats.skipped >= 1, - "expected skipped >= 1, got {}", - res.stats.skipped - ); + assert!(res.stats.skipped >= 1, "expected skipped >= 1, got {}", res.stats.skipped); assert_eq!(res.stats.files, 0); } @@ -309,9 +297,7 @@ mod tests { #[test] fn metadata_failure_counts_as_skipped() { use std::os::unix::fs::PermissionsExt; - if running_as_root() { - return; - } + if running_as_root() { return; } let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let noexec = root.join("noexec"); @@ -323,11 +309,7 @@ mod tests { let res = scan_dir(root, &AtomicBool::new(false), noop); fs::set_permissions(&noexec, fs::Permissions::from_mode(0o755)).unwrap(); - assert!( - res.stats.skipped >= 1, - "expected skipped >= 1, got {}", - res.stats.skipped - ); + assert!(res.stats.skipped >= 1, "expected skipped >= 1, got {}", res.stats.skipped); assert_eq!(res.stats.bytes, 0); } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 26dd1a63c..44447af0c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -6,9 +6,7 @@ pub struct Settings { } impl Default for Settings { - fn default() -> Self { - Settings { online_mode: false } - } + fn default() -> Self { Settings { online_mode: false } } } /// JSON → Settings. 손상/부분 JSON은 기본값(offline)으로 fail-safe — 설정 파일이 앱을 깨지 않게. diff --git a/src-tauri/src/userrules.rs b/src-tauri/src/userrules.rs index acf2501a0..fa4482abc 100644 --- a/src-tauri/src/userrules.rs +++ b/src-tauri/src/userrules.rs @@ -5,20 +5,13 @@ use std::path::Path; #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub struct RuleMatch { - #[serde(default)] - pub ext: Option, - #[serde(default)] - pub name_contains: Option, - #[serde(default)] - pub path_contains: Option, - #[serde(default)] - pub min_size: Option, - #[serde(default)] - pub max_size: Option, - #[serde(default)] - pub min_age_days: Option, - #[serde(default)] - pub max_age_days: Option, + #[serde(default)] pub ext: Option, + #[serde(default)] pub name_contains: Option, + #[serde(default)] pub path_contains: Option, + #[serde(default)] pub min_size: Option, + #[serde(default)] pub max_size: Option, + #[serde(default)] pub min_age_days: Option, + #[serde(default)] pub max_age_days: Option, } #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] @@ -35,55 +28,27 @@ pub fn parse_rules(json: &str) -> Result, String> { /// 첫 매칭 규칙의 클래스. 매칭 규칙 없으면 None. pub fn classify_by_rules(rules: &[Rule], path: &Path, size: u64, age_days: u64) -> Option { - rules - .iter() - .find(|r| rule_matches(&r.r#match, path, size, age_days)) - .map(|r| r.class.clone()) + rules.iter().find(|r| rule_matches(&r.r#match, path, size, age_days)).map(|r| r.class.clone()) } /// 존재하는 모든 술어가 AND로 일치해야 매칭. 술어 전무(all-None)면 catch-all(true). fn rule_matches(m: &RuleMatch, path: &Path, size: u64, age_days: u64) -> bool { if let Some(ext) = &m.ext { let want = ext.to_lowercase(); - let got = path - .extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()); - if got.as_deref() != Some(want.as_str()) { - return false; - } + let got = path.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()); + if got.as_deref() != Some(want.as_str()) { return false; } } if let Some(sub) = &m.name_contains { let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if !name.contains(sub.as_str()) { - return false; - } + if !name.contains(sub.as_str()) { return false; } } if let Some(sub) = &m.path_contains { - if !path.to_string_lossy().contains(sub.as_str()) { - return false; - } - } - if let Some(min) = m.min_size { - if size < min { - return false; - } - } - if let Some(max) = m.max_size { - if size > max { - return false; - } - } - if let Some(min) = m.min_age_days { - if age_days < min { - return false; - } - } - if let Some(max) = m.max_age_days { - if age_days > max { - return false; - } + if !path.to_string_lossy().contains(sub.as_str()) { return false; } } + if let Some(min) = m.min_size { if size < min { return false; } } + if let Some(max) = m.max_size { if size > max { return false; } } + if let Some(min) = m.min_age_days { if age_days < min { return false; } } + if let Some(max) = m.max_age_days { if age_days > max { return false; } } true } @@ -92,17 +57,7 @@ mod tests { use super::*; use std::path::PathBuf; - fn m() -> RuleMatch { - RuleMatch { - ext: None, - name_contains: None, - path_contains: None, - min_size: None, - max_size: None, - min_age_days: None, - max_age_days: None, - } - } + fn m() -> RuleMatch { RuleMatch { ext: None, name_contains: None, path_contains: None, min_size: None, max_size: None, min_age_days: None, max_age_days: None } } #[test] fn parse_valid_and_malformed() { @@ -117,175 +72,63 @@ mod tests { #[test] fn ext_predicate_case_insensitive() { - let r = vec![Rule { - r#match: RuleMatch { - ext: Some("ISO".into()), - ..m() - }, - class: "Installer".into(), - }]; - assert_eq!( - classify_by_rules(&r, &PathBuf::from("/d/x.iso"), 0, 0).as_deref(), - Some("Installer") - ); - assert_eq!( - classify_by_rules(&r, &PathBuf::from("/d/x.zip"), 0, 0), - None - ); // 확장자 불일치 - assert_eq!( - classify_by_rules(&r, &PathBuf::from("/d/noext"), 0, 0), - None - ); // 확장자 없음 + let r = vec![Rule { r#match: RuleMatch { ext: Some("ISO".into()), ..m() }, class: "Installer".into() }]; + assert_eq!(classify_by_rules(&r, &PathBuf::from("/d/x.iso"), 0, 0).as_deref(), Some("Installer")); + assert_eq!(classify_by_rules(&r, &PathBuf::from("/d/x.zip"), 0, 0), None); // 확장자 불일치 + assert_eq!(classify_by_rules(&r, &PathBuf::from("/d/noext"), 0, 0), None); // 확장자 없음 } #[test] fn name_and_path_contains() { - let rn = vec![Rule { - r#match: RuleMatch { - name_contains: Some("backup".into()), - ..m() - }, - class: "Archive".into(), - }]; - assert_eq!( - classify_by_rules(&rn, &PathBuf::from("/d/my_backup.tar"), 0, 0).as_deref(), - Some("Archive") - ); - assert_eq!( - classify_by_rules(&rn, &PathBuf::from("/d/report.tar"), 0, 0), - None - ); + let rn = vec![Rule { r#match: RuleMatch { name_contains: Some("backup".into()), ..m() }, class: "Archive".into() }]; + assert_eq!(classify_by_rules(&rn, &PathBuf::from("/d/my_backup.tar"), 0, 0).as_deref(), Some("Archive")); + assert_eq!(classify_by_rules(&rn, &PathBuf::from("/d/report.tar"), 0, 0), None); assert_eq!(classify_by_rules(&rn, &PathBuf::from("/"), 0, 0), None); // 파일명 없음 → "" → 불일치 - let rp = vec![Rule { - r#match: RuleMatch { - path_contains: Some("Downloads".into()), - ..m() - }, - class: "Dl".into(), - }]; - assert_eq!( - classify_by_rules(&rp, &PathBuf::from("/home/Downloads/x.bin"), 0, 0).as_deref(), - Some("Dl") - ); - assert_eq!( - classify_by_rules(&rp, &PathBuf::from("/home/Docs/x.bin"), 0, 0), - None - ); + let rp = vec![Rule { r#match: RuleMatch { path_contains: Some("Downloads".into()), ..m() }, class: "Dl".into() }]; + assert_eq!(classify_by_rules(&rp, &PathBuf::from("/home/Downloads/x.bin"), 0, 0).as_deref(), Some("Dl")); + assert_eq!(classify_by_rules(&rp, &PathBuf::from("/home/Docs/x.bin"), 0, 0), None); } #[test] fn size_bounds_inclusive() { - let r = vec![Rule { - r#match: RuleMatch { - min_size: Some(100), - max_size: Some(200), - ..m() - }, - class: "Mid".into(), - }]; - assert_eq!( - classify_by_rules(&r, &PathBuf::from("/x"), 100, 0).as_deref(), - Some("Mid") - ); // 하한 포함 - assert_eq!( - classify_by_rules(&r, &PathBuf::from("/x"), 200, 0).as_deref(), - Some("Mid") - ); // 상한 포함 - assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 99, 0), None); // 하한 미만 + let r = vec![Rule { r#match: RuleMatch { min_size: Some(100), max_size: Some(200), ..m() }, class: "Mid".into() }]; + assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 100, 0).as_deref(), Some("Mid")); // 하한 포함 + assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 200, 0).as_deref(), Some("Mid")); // 상한 포함 + assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 99, 0), None); // 하한 미만 assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 201, 0), None); // 상한 초과 } #[test] fn age_bounds_inclusive() { - let r = vec![Rule { - r#match: RuleMatch { - min_age_days: Some(30), - max_age_days: Some(90), - ..m() - }, - class: "Stale".into(), - }]; - assert_eq!( - classify_by_rules(&r, &PathBuf::from("/x"), 0, 30).as_deref(), - Some("Stale") - ); // 하한 포함 - assert_eq!( - classify_by_rules(&r, &PathBuf::from("/x"), 0, 90).as_deref(), - Some("Stale") - ); // 상한 포함 + let r = vec![Rule { r#match: RuleMatch { min_age_days: Some(30), max_age_days: Some(90), ..m() }, class: "Stale".into() }]; + assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 0, 30).as_deref(), Some("Stale")); // 하한 포함 + assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 0, 90).as_deref(), Some("Stale")); // 상한 포함 assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 0, 29), None); // 하한 미만 assert_eq!(classify_by_rules(&r, &PathBuf::from("/x"), 0, 91), None); // 상한 초과 } #[test] fn age_ands_with_other_predicates() { - let r = vec![Rule { - r#match: RuleMatch { - ext: Some("iso".into()), - min_age_days: Some(365), - ..m() - }, - class: "OldIso".into(), - }]; - assert_eq!( - classify_by_rules(&r, &PathBuf::from("/x.iso"), 0, 400).as_deref(), - Some("OldIso") - ); - assert_eq!( - classify_by_rules(&r, &PathBuf::from("/x.iso"), 0, 100), - None - ); // ext OK, age 미달 → AND 실패 + let r = vec![Rule { r#match: RuleMatch { ext: Some("iso".into()), min_age_days: Some(365), ..m() }, class: "OldIso".into() }]; + assert_eq!(classify_by_rules(&r, &PathBuf::from("/x.iso"), 0, 400).as_deref(), Some("OldIso")); + assert_eq!(classify_by_rules(&r, &PathBuf::from("/x.iso"), 0, 100), None); // ext OK, age 미달 → AND 실패 } #[test] fn and_semantics_and_first_match_wins_and_catch_all() { // AND: ext+min_size 둘 다 만족해야 - let r = vec![Rule { - r#match: RuleMatch { - ext: Some("mp4".into()), - min_size: Some(1000), - ..m() - }, - class: "BigVid".into(), - }]; - assert_eq!( - classify_by_rules(&r, &PathBuf::from("/x.mp4"), 2000, 0).as_deref(), - Some("BigVid") - ); - assert_eq!( - classify_by_rules(&r, &PathBuf::from("/x.mp4"), 500, 0), - None - ); // ext OK, size 미달 → AND 실패 - // 첫 매칭 승리 + let r = vec![Rule { r#match: RuleMatch { ext: Some("mp4".into()), min_size: Some(1000), ..m() }, class: "BigVid".into() }]; + assert_eq!(classify_by_rules(&r, &PathBuf::from("/x.mp4"), 2000, 0).as_deref(), Some("BigVid")); + assert_eq!(classify_by_rules(&r, &PathBuf::from("/x.mp4"), 500, 0), None); // ext OK, size 미달 → AND 실패 + // 첫 매칭 승리 let ord = vec![ - Rule { - r#match: RuleMatch { - ext: Some("log".into()), - ..m() - }, - class: "First".into(), - }, - Rule { - r#match: RuleMatch { - ext: Some("log".into()), - ..m() - }, - class: "Second".into(), - }, + Rule { r#match: RuleMatch { ext: Some("log".into()), ..m() }, class: "First".into() }, + Rule { r#match: RuleMatch { ext: Some("log".into()), ..m() }, class: "Second".into() }, ]; - assert_eq!( - classify_by_rules(&ord, &PathBuf::from("/x.log"), 0, 0).as_deref(), - Some("First") - ); + assert_eq!(classify_by_rules(&ord, &PathBuf::from("/x.log"), 0, 0).as_deref(), Some("First")); // all-None catch-all - let catch = vec![Rule { - r#match: m(), - class: "Any".into(), - }]; - assert_eq!( - classify_by_rules(&catch, &PathBuf::from("/anything.zzz"), 0, 0).as_deref(), - Some("Any") - ); + let catch = vec![Rule { r#match: m(), class: "Any".into() }]; + assert_eq!(classify_by_rules(&catch, &PathBuf::from("/anything.zzz"), 0, 0).as_deref(), Some("Any")); // 빈 규칙 → None assert_eq!(classify_by_rules(&[], &PathBuf::from("/x"), 0, 0), None); } diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index 6974bb2d6..468cfcc4c 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -21,11 +21,7 @@ pub fn ddg_query(ext: &str) -> String { pub fn parse_ddg_abstract(json: &str) -> Option { let v = serde_json::from_str::(json).ok()?; let s = v.get("AbstractText")?.as_str()?; - if s.is_empty() { - None - } else { - Some(s.to_string()) - } + if s.is_empty() { None } else { Some(s.to_string()) } } #[cfg(test)] @@ -41,13 +37,11 @@ mod tests { } #[test] fn abstract_extracted_or_none() { - assert_eq!( - parse_ddg_abstract(r#"{"AbstractText":"Autodesk FBX is a 3D format."}"#), - Some("Autodesk FBX is a 3D format.".to_string()) - ); + assert_eq!(parse_ddg_abstract(r#"{"AbstractText":"Autodesk FBX is a 3D format."}"#), + Some("Autodesk FBX is a 3D format.".to_string())); assert_eq!(parse_ddg_abstract(r#"{"AbstractText":""}"#), None); // 빈 abstract - assert_eq!(parse_ddg_abstract(r#"{"Heading":"x"}"#), None); // 필드 없음 - assert_eq!(parse_ddg_abstract("not json"), None); // 파싱 실패 + assert_eq!(parse_ddg_abstract(r#"{"Heading":"x"}"#), None); // 필드 없음 + assert_eq!(parse_ddg_abstract("not json"), None); // 파싱 실패 assert_eq!(parse_ddg_abstract(r#"{"AbstractText":5}"#), None); // 문자열 아님 } } diff --git a/src-tauri/tests/cloud_transfer_coverage_contract.rs b/src-tauri/tests/cloud_transfer_coverage_contract.rs index f75223ada..245a5d299 100644 --- a/src-tauri/tests/cloud_transfer_coverage_contract.rs +++ b/src-tauri/tests/cloud_transfer_coverage_contract.rs @@ -68,10 +68,7 @@ fn cloud_plan_exports_backend_authored_approval_phrase() { "pub copy_approval_max_age_ms: u64", "cloud_copy_approval_phrase(&candidate, action)", ] { - assert!( - view_source.contains(marker), - "missing backend plan-view marker: {marker}" - ); + assert!(view_source.contains(marker), "missing backend plan-view marker: {marker}"); } assert!( command_source.contains("Result"), @@ -83,10 +80,7 @@ fn cloud_plan_exports_backend_authored_approval_phrase() { "copy_approval_max_age_ms?: number", "/** Returns the exact backend-authored phrase only for the matching candidate action. */", ] { - assert!( - api_source.contains(marker), - "missing frontend plan contract: {marker}" - ); + assert!(api_source.contains(marker), "missing frontend plan contract: {marker}"); } assert!( !api_source.contains("`DiskSage cloud ${action} ${candidate.review_fingerprint} 승인`"), diff --git a/src-tauri/tests/icloud_local_eviction_batch_documentation_test.rs b/src-tauri/tests/icloud_local_eviction_batch_documentation_test.rs index 988145e9d..84a698ee2 100644 --- a/src-tauri/tests/icloud_local_eviction_batch_documentation_test.rs +++ b/src-tauri/tests/icloud_local_eviction_batch_documentation_test.rs @@ -4,7 +4,8 @@ //! references reviewable alongside the Rust behavior they describe. /// Operator guide compiled into the test binary so documentation claims are checked offline. -const OPERATOR_GUIDE: &str = include_str!("../../docs/development/icloud-local-eviction-batch.md"); +const OPERATOR_GUIDE: &str = + include_str!("../../docs/development/icloud-local-eviction-batch.md"); /// Verifies that the operator guide maps fail-closed behavior to authoritative controls. #[test] From 99c745c9698af1e0d706075c1bfda65d912c14b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 03:09:23 +0900 Subject: [PATCH 44/66] ci: scope Rust formatting to Podman slice --- .github/workflows/test.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6c5ccec8..0a5a55f89 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,8 +23,12 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: src-tauri - - name: Rust formatting - run: cargo fmt --manifest-path src-tauri/Cargo.toml -- --check + - name: Podman Rust formatting + run: >- + rustfmt --edition 2021 --check + src-tauri/src/podman_desktop.rs + src-tauri/tests/podman_desktop_documentation_contract.rs + src-tauri/tests/podman_desktop_issue_privacy.rs - name: Rust tests (includes unix symlink test) run: cargo test --manifest-path src-tauri/Cargo.toml - name: Headless cloud planner tests From b7f980d265713d5ffb84f744ce454589e3d410ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 03:10:09 +0900 Subject: [PATCH 45/66] chore: keep Podman registration diff focused --- src-tauri/src/lib.rs | 80 ++++++++++++++++++++++---------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 61df916fc..d466f3c41 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,77 +1,77 @@ // coverage 빌드(비-테스트)에서는 run()이 빠져 모듈 내용이 테스트에서만 쓰이므로 dead_code만 허용 +#[cfg_attr(coverage, allow(dead_code))] +mod dupes; +#[cfg_attr(coverage, allow(dead_code))] +mod commands; +#[cfg_attr(coverage, allow(dead_code))] +mod scanner; +#[cfg_attr(coverage, allow(dead_code))] +mod userrules; +#[cfg_attr(coverage, allow(dead_code))] +mod settings; +#[cfg_attr(coverage, allow(dead_code))] +mod safety; +#[cfg(all(test, target_os = "macos"))] +mod macos_temp_guard_tests; +#[cfg_attr(coverage, allow(dead_code))] +mod rules; +#[cfg_attr(coverage, allow(dead_code))] +mod dev_artifacts; +#[cfg_attr(coverage, allow(dead_code))] +mod ontology; +#[cfg_attr(coverage, allow(dead_code))] +mod inventory; +#[cfg_attr(coverage, allow(dead_code))] +mod organize; +#[cfg_attr(coverage, allow(dead_code))] +mod llm; +#[cfg_attr(coverage, allow(dead_code))] +mod web; +#[cfg_attr(coverage, allow(dead_code))] +mod reasoning; +#[cfg_attr(coverage, allow(dead_code))] +mod dataset_metadata; pub mod archive_git_tree; #[cfg_attr(coverage, allow(dead_code))] pub mod cloud; -#[cfg(not(coverage))] -pub mod cloud_eviction; -pub mod cloud_local_eviction; -pub mod cloud_local_eviction_batch; -pub mod cloud_local_inventory; /// Typed backend-authored presentation contract for cloud archive plans. pub mod cloud_plan_view; +pub mod cloud_local_inventory; +pub mod cloud_local_eviction; +pub mod cloud_local_eviction_batch; +#[cfg(not(coverage))] +pub mod cloud_eviction; pub mod cloud_review; pub mod cloud_transfer; -#[cfg_attr(coverage, allow(dead_code))] -mod commands; pub mod content_digest; -#[cfg_attr(coverage, allow(dead_code))] -mod dataset_metadata; -#[cfg_attr(coverage, allow(dead_code))] -mod dev_artifacts; -#[cfg_attr(coverage, allow(dead_code))] -mod dupes; pub mod duplicate_audit; -pub mod git_worktree; pub mod icloud_sync_health; pub mod incomplete_download; pub mod incomplete_download_materialization; pub mod incomplete_download_materialization_destination; pub mod incomplete_download_materialization_execution; pub mod incomplete_download_recovery; -#[cfg_attr(coverage, allow(dead_code))] -mod inventory; -#[cfg_attr(coverage, allow(dead_code))] -mod llm; -#[cfg(all(test, target_os = "macos"))] -mod macos_temp_guard_tests; +pub mod git_worktree; pub mod maven_cache; pub mod multipart_archive; pub mod naruon_capacity; pub mod naruon_cloud_copy_readiness; pub mod naruon_lineage; -#[cfg_attr(coverage, allow(dead_code))] -mod ontology; -#[cfg_attr(coverage, allow(dead_code))] -mod organize; /// Privacy-safe desktop projection of read-only Podman reclaim evidence. pub mod podman_desktop; /// Read-only, fail-closed Podman VM/store reclaim evidence. pub mod podman_reclaim; -pub mod private_evidence; pub mod provider_api_client; pub mod provider_capacity; pub mod provider_client_runtime; pub mod provider_evidence; pub mod provider_oauth; pub mod provider_sync; -#[cfg_attr(coverage, allow(dead_code))] -mod reasoning; +pub mod private_evidence; /// Read-only, fail-closed logical/allocation/reclaimability evidence. pub mod reclaim; -#[cfg_attr(coverage, allow(dead_code))] -mod rules; -#[cfg_attr(coverage, allow(dead_code))] -mod safety; -#[cfg_attr(coverage, allow(dead_code))] -mod scanner; pub mod semantic_catalog; -#[cfg_attr(coverage, allow(dead_code))] -mod settings; -#[cfg_attr(coverage, allow(dead_code))] -mod userrules; pub mod volume_pressure; -#[cfg_attr(coverage, allow(dead_code))] -mod web; // coverage 빌드에서 제외 — GUI 런타임은 헤드리스 테스트로 실행 불가 #[cfg(not(coverage))] @@ -126,7 +126,7 @@ pub fn run() { commands::adopt_existing_cloud_candidate, commands::attest_cloud_copy, commands::trash_verified_cloud_source, - podman_desktop::inspect_podman_reclaim, + podman_desktop::inspect_podman_reclaim ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); From 3d012e74342eef144ca69c4f36444d2172fdfdcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 01:46:45 +0900 Subject: [PATCH 46/66] test(ci): require exact-head coverage evidence --- src/lib/coverageEvidenceWorkflow.test.ts | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/lib/coverageEvidenceWorkflow.test.ts diff --git a/src/lib/coverageEvidenceWorkflow.test.ts b/src/lib/coverageEvidenceWorkflow.test.ts new file mode 100644 index 000000000..79cb62989 --- /dev/null +++ b/src/lib/coverageEvidenceWorkflow.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const workflow = readFileSync( + new URL('../../.github/workflows/test.yml', import.meta.url), + 'utf8', +); + +describe('Test workflow coverage evidence contract', () => { + it('binds coverage evidence to the exact pull-request head', () => { + expect(workflow).toContain( + 'ref: ${{ github.event.pull_request.head.sha || github.sha }}', + ); + expect(workflow).toContain( + 'HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}', + ); + }); + + it('measures Rust branch coverage instead of synthesizing percentages', () => { + expect(workflow).toContain('tool: cargo-llvm-cov'); + expect(workflow).toContain( + 'cargo llvm-cov --manifest-path src-tauri/Cargo.toml --branch --json --summary-only --output-path coverage.json', + ); + expect(workflow).toContain('coverage.json'); + expect(workflow).toContain('coverage-evidence.json'); + }); + + it('uploads fail-closed evidence under the organization contract name', () => { + expect(workflow).toContain('name: coverage-evidence'); + expect(workflow).toContain('path: coverage-evidence.json'); + expect(workflow).toContain('if-no-files-found: error'); + expect(workflow).toContain('statement_coverage'); + expect(workflow).toContain('branch_coverage'); + expect(workflow).toContain('function_coverage'); + expect(workflow).toContain('line_coverage'); + }); +}); From 93c7f27bb6b3f4ce2a839dcbb541a7635102af8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 01:54:18 +0900 Subject: [PATCH 47/66] ci: emit exact-head Rust coverage evidence --- .github/workflows/test.yml | 81 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0a5a55f89..d57853f66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -50,6 +50,87 @@ jobs: - run: npm run check - run: npm run build + coverage-evidence: + runs-on: ubuntu-latest + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Install Tauri system deps + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: nightly-2026-08-07 + components: llvm-tools-preview + - uses: taiki-e/install-action@6c6fd71fe4fb72c3697d269963d0e15df8adedad + with: + tool: cargo-llvm-cov + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: src-tauri + - name: Measure exact-head Rust coverage + run: cargo llvm-cov --manifest-path src-tauri/Cargo.toml --branch --json --summary-only --output-path coverage.json + - name: Build exact-head coverage evidence + run: | + node --input-type=module <<'NODE' + import { readFileSync, writeFileSync } from 'node:fs'; + + const sha = process.env.HEAD_SHA ?? ''; + const repository = process.env.GITHUB_REPOSITORY ?? ''; + if (!/^[0-9a-f]{40}$/.test(sha) || repository.length === 0) { + throw new Error('coverage evidence identity is invalid'); + } + + const report = JSON.parse(readFileSync('coverage.json', 'utf8')); + const totals = report?.data?.[0]?.totals; + const metric = (name, value) => { + if ( + !value || + !Number.isFinite(value.count) || + !Number.isFinite(value.covered) || + !Number.isFinite(value.percent) || + value.count <= 0 || + value.covered !== value.count || + value.percent !== 100 + ) { + throw new Error(`${name} coverage is not exactly 100%`); + } + return value.percent; + }; + + const evidence = { + schema_version: 1, + head_sha: sha, + commit_sha: sha, + repository, + trust_tier: 'ci-verified', + ci_server: 'github-actions', + ci_workflow: 'Test', + coverage_command: 'cargo llvm-cov', + statement_coverage: metric('statement/region', totals?.regions), + branch_coverage: metric('branch', totals?.branches), + function_coverage: metric('function', totals?.functions), + line_coverage: metric('line', totals?.lines), + passed: true, + }; + + writeFileSync( + 'coverage-evidence.json', + `${JSON.stringify(evidence, null, 2)}\n`, + ); + NODE + - name: Upload coverage evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-evidence + path: coverage-evidence.json + if-no-files-found: error + llm-engine-build: runs-on: ubuntu-latest steps: From 6b5c1b08853eacc77418026232ef031e78239ac5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 01:55:30 +0900 Subject: [PATCH 48/66] docs(ci): document exact-head coverage evidence --- docs/development/coverage-evidence.md | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/development/coverage-evidence.md diff --git a/docs/development/coverage-evidence.md b/docs/development/coverage-evidence.md new file mode 100644 index 000000000..eaeacda83 --- /dev/null +++ b/docs/development/coverage-evidence.md @@ -0,0 +1,40 @@ +# Exact-head coverage evidence + +DiskSage treats code coverage as durable CI evidence rather than a locally asserted percentage. The `Test` workflow measures Rust production coverage on the exact pull-request head and emits a machine-readable `coverage-evidence` artifact only when all required metrics are exactly 100%. + +## Why the workflow checks out the exact head + +GitHub pull-request workflows may otherwise execute against a synthetic merge commit. That is useful for integration testing, but it is not sufficient for a review gate that claims a result about one immutable pull-request head. The coverage job therefore checks out `${{ github.event.pull_request.head.sha || github.sha }}` explicitly and copies the same value into `HEAD_SHA`. + +The evidence builder rejects a missing or malformed SHA. Both `head_sha` and `commit_sha` in `coverage-evidence.json` must equal that exact 40-character commit identifier. The repository identity is taken from `GITHUB_REPOSITORY`, not from user-controlled test output. + +## What is measured + +The workflow uses `cargo llvm-cov` with LLVM source-based instrumentation. Branch coverage is requested explicitly with `--branch`; because cargo-llvm-cov documents branch coverage as unstable, the workflow uses an immutable dated Rust nightly with `llvm-tools-preview` instead of silently falling back to a toolchain that cannot measure the required metric. + +The JSON summary is the only source for the emitted percentages. The evidence builder reads LLVM's aggregate totals and requires all of the following to be present, finite, non-empty, fully covered, and exactly 100%: + +- statement coverage: LLVM region coverage, used as the statement-equivalent source-based metric; +- branch coverage: LLVM branch totals; +- function coverage: LLVM function totals; and +- line coverage: LLVM line totals. + +The workflow never manufactures a percentage from a successful test exit status. Missing totals, zero denominators, partial coverage, malformed JSON, or identity drift stop the job before the artifact can be uploaded. + +## Evidence contract + +A valid `coverage-evidence.json` has schema version `1` and records the immutable head, repository, CI trust tier, server, workflow name, coverage command, four exact percentages, and `passed: true`. The organization review workflow independently downloads this artifact from the successful `Test` run for the same head and revalidates the contract. + +The artifact is uploaded with `if-no-files-found: error`. GitHub Actions artifacts persist workflow outputs such as test and coverage results after the producing job completes, which lets the organization-level reviewer consume evidence without granting the coverage job repository-write permission. + +## Fail-closed operating rule + +A missing `coverage-evidence` artifact is not equivalent to passing coverage. A queued, cancelled, failed, stale-head, malformed, or less-than-100% measurement is also not passing. Engineers must add realistic tests or remove genuinely unreachable production code; they must not lower thresholds, hard-code percentages, exclude reachable production arithmetic merely to satisfy the gate, or reuse an artifact from an older head. + +## References + +GitHub. (2026). *Store and share data with workflow artifacts*. GitHub Docs. https://docs.github.com/en/actions/tutorials/store-and-share-data + +GitHub. (2026). *Workflow artifacts*. GitHub Docs. https://docs.github.com/en/actions/concepts/workflows-and-actions/workflow-artifacts + +Taiki Endo. (2026). *cargo-llvm-cov: Cargo subcommand to easily use LLVM source-based code coverage* [Computer software]. GitHub. https://github.com/taiki-e/cargo-llvm-cov From 378d4b297cb6baa1be0e6d84837ae8a06e2c5338 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 8 Aug 2026 01:56:10 +0900 Subject: [PATCH 49/66] docs(changelog): record coverage evidence gate --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72f6044f2..7391ebb72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Changed +- Require the `Test` workflow to produce exact-head, branch-aware, fail-closed Rust coverage evidence from real `cargo llvm-cov` measurements before organization-level review can treat 100% statement-equivalent region, branch, function, and line coverage as passing. - Require a fresh, exact, human-attributed approval and rationale for cloud copy-only and existing-copy adoption actions, with a 15-minute authorization lifetime bound to the candidate, destination, provider, account scope, and review fingerprint. - Return the candidate-specific cloud copy approval action, exact confirmation phrase, and maximum approval age from the Rust plan contract; the frontend only displays and submits that backend-authored phrase and fails closed when it is missing or does not match the candidate action. - Align the frontend toolchain on Vite 8.2 and `@sveltejs/vite-plugin-svelte` 7.2 so the declared peer dependency graph is installable and reproducible. From ba76e31159f9ea8e18792afeda79886c28be9d55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 04:12:45 +0900 Subject: [PATCH 50/66] test: cover Podman desktop validation branches --- .../tests/podman_desktop_branch_coverage.rs | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 src-tauri/tests/podman_desktop_branch_coverage.rs diff --git a/src-tauri/tests/podman_desktop_branch_coverage.rs b/src-tauri/tests/podman_desktop_branch_coverage.rs new file mode 100644 index 000000000..4e7292eb5 --- /dev/null +++ b/src-tauri/tests/podman_desktop_branch_coverage.rs @@ -0,0 +1,178 @@ +use disksage_lib::podman_desktop::redact_podman_reclaim_plan; +use disksage_lib::podman_reclaim::{ + GuestFilesystemEvidence, PodmanMachineEvidence, PodmanReclaimAssessment, PodmanReclaimPlan, + PodmanRecommendedAction, PodmanRecommendedActionKind, PodmanStoreEvidence, + PodmanSystemDfCategoryEvidence, PodmanSystemDfEvidence, PodmanUnusedImageEvidence, + RawImageEvidence, PODMAN_RECLAIM_SCHEMA_KIND, +}; + +/// Build one deterministic `podman system df` category for projection tests. +fn category(reclaimable_bytes: u64) -> PodmanSystemDfCategoryEvidence { + PodmanSystemDfCategoryEvidence { + total: 2, + active: 1, + size_bytes: reclaimable_bytes.saturating_add(10), + reclaimable_bytes, + } +} + +/// Build a complete plan whose private identifiers must never cross the desktop boundary. +fn complete_plan() -> PodmanReclaimPlan { + PodmanReclaimPlan { + schema_kind: PODMAN_RECLAIM_SCHEMA_KIND, + schema_version: 3, + platform: "macos", + evidence_complete: true, + elapsed_ms: 17, + machine: Some(PodmanMachineEvidence { + name: "private-machine".to_string(), + state: "running".to_string(), + configured_disk_bytes: Some(1_000), + }), + raw_image: Some(RawImageEvidence { + path: "/Users/private/.local/share/private-machine.raw".to_string(), + logical_bytes: 900, + allocated_bytes: Some(700), + }), + guest_filesystem: Some(GuestFilesystemEvidence { + total_bytes: 800, + used_bytes: 500, + available_bytes: 300, + }), + store: Some(PodmanStoreEvidence { + graph_root: "/var/home/private/containers".to_string(), + graph_root_allocated_bytes: 600, + graph_root_used_bytes: 450, + images: 4, + containers_total: 3, + containers_running: 1, + containers_stopped: 2, + }), + system_df: Some(PodmanSystemDfEvidence { + images: category(200), + containers: category(30), + local_volumes: category(70), + }), + unused_images: Some(PodmanUnusedImageEvidence { + total_records: 4, + referenced_records: 2, + unused_records: 2, + unused_untagged_records: 1, + unused_tagged_records: 1, + candidate_record_size_sum: 200, + candidate_set_sha256: "abcdef0123456789".repeat(4), + }), + assessment: PodmanReclaimAssessment { + physically_reclaimable_bytes: None, + podman_reported_reclaimable_bytes: Some(300), + raw_allocated_minus_guest_used_bytes: Some(200), + status: "unverified".to_string(), + reason_codes: vec!["host-physical-reclaim-unverified".to_string()], + recommended_actions: vec![], + }, + issues: vec![], + } +} + +/// Exercise every character-class and length boundary of privacy-safe issue-code admission. +#[test] +fn issue_code_projection_covers_length_prefix_and_character_boundaries() { + let mut plan = complete_plan(); + plan.issues = vec![ + "stable-code9:private-detail".to_string(), + "stable--0".to_string(), + "a".repeat(97), + "1starts-with-digit".to_string(), + "-starts-with-hyphen".to_string(), + "with space".to_string(), + "éclair".to_string(), + ]; + + let evidence = redact_podman_reclaim_plan(plan); + + assert!(evidence.issue_codes.contains(&"stable-code9".to_string())); + assert!(evidence.issue_codes.contains(&"stable--0".to_string())); + assert!(evidence + .issue_codes + .contains(&"podman-evidence-error".to_string())); + assert_eq!( + evidence + .issue_codes + .iter() + .filter(|code| code.as_str() == "podman-evidence-error") + .count(), + 1 + ); +} + +/// Reject lowercase non-hexadecimal fingerprints that otherwise satisfy the exact length bound. +#[test] +fn fingerprint_validation_rejects_lowercase_non_hex_at_exact_length() { + let mut plan = complete_plan(); + plan.unused_images + .as_mut() + .expect("fixture has unused image evidence") + .candidate_set_sha256 = "g".repeat(64); + + let evidence = redact_podman_reclaim_plan(plan); + + assert!(!evidence.evidence_complete); + assert_eq!(evidence.candidates.image_candidate_set_sha256, None); + assert!(evidence + .issue_codes + .contains(&"podman-desktop-invalid-candidate-fingerprint".to_string())); +} + +/// Distinguish a matching action without approval from unrelated and approving actions. +#[test] +fn review_boundaries_require_both_matching_kind_and_human_approval() { + let mut plan = complete_plan(); + plan.assessment.recommended_actions = vec![ + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewUnusedImages, + requires_human_approval: false, + rationale: "image observation only".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::InvestigateApi, + requires_human_approval: true, + rationale: "unrelated approval".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewStoppedContainers, + requires_human_approval: true, + rationale: "container review".to_string(), + }, + PodmanRecommendedAction { + kind: PodmanRecommendedActionKind::ReviewUnusedVolumes, + requires_human_approval: false, + rationale: "volume observation only".to_string(), + }, + ]; + + let evidence = redact_podman_reclaim_plan(plan); + + assert!(!evidence.review_boundaries.image_review_required); + assert!(evidence.review_boundaries.stopped_container_review_required); + assert!(!evidence.review_boundaries.volume_review_required); +} + +/// Preserve unknown inner optional measurements even when their enclosing observations exist. +#[test] +fn nested_optional_capacity_values_remain_unknown() { + let mut plan = complete_plan(); + plan.machine + .as_mut() + .expect("fixture has machine evidence") + .configured_disk_bytes = None; + plan.raw_image + .as_mut() + .expect("fixture has raw-image evidence") + .allocated_bytes = None; + + let evidence = redact_podman_reclaim_plan(plan); + + assert_eq!(evidence.capacity.configured_disk_bytes, None); + assert_eq!(evidence.capacity.host_allocated_bytes, None); + assert_eq!(evidence.capacity.raw_logical_bytes, Some(900)); +} From 052aedf108ef8aa2a243698409c1ee16edf97b29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 08:15:38 +0900 Subject: [PATCH 51/66] test: require production Rust coverage graph --- src/lib/coverageEvidenceWorkflow.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/coverageEvidenceWorkflow.test.ts b/src/lib/coverageEvidenceWorkflow.test.ts index 79cb62989..18d8beec9 100644 --- a/src/lib/coverageEvidenceWorkflow.test.ts +++ b/src/lib/coverageEvidenceWorkflow.test.ts @@ -25,6 +25,11 @@ describe('Test workflow coverage evidence contract', () => { expect(workflow).toContain('coverage-evidence.json'); }); + it('measures the production Rust graph instead of cfg-pruned substitutes', () => { + expect(workflow).toContain('--no-cfg-coverage'); + expect(workflow).toContain('--no-cfg-coverage-nightly'); + }); + it('uploads fail-closed evidence under the organization contract name', () => { expect(workflow).toContain('name: coverage-evidence'); expect(workflow).toContain('path: coverage-evidence.json'); @@ -34,4 +39,4 @@ describe('Test workflow coverage evidence contract', () => { expect(workflow).toContain('function_coverage'); expect(workflow).toContain('line_coverage'); }); -}); +}); \ No newline at end of file From 5a767954eacd979530fe45fcbd5b4d12d0bfb027 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 09:11:02 +0900 Subject: [PATCH 52/66] ci: measure production graph in Rust coverage --- .github/workflows/test.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d57853f66..42034b3a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,7 +74,10 @@ jobs: with: workspaces: src-tauri - name: Measure exact-head Rust coverage - run: cargo llvm-cov --manifest-path src-tauri/Cargo.toml --branch --json --summary-only --output-path coverage.json + run: >- + cargo llvm-cov --manifest-path src-tauri/Cargo.toml + --branch --no-cfg-coverage --no-cfg-coverage-nightly + --json --summary-only --output-path coverage.json - name: Build exact-head coverage evidence run: | node --input-type=module <<'NODE' From 6fd3c627f02125dc20926a049ca901eae9b344fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 09:11:39 +0900 Subject: [PATCH 53/66] ci: keep coverage contract executable --- .github/workflows/test.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 42034b3a1..295bb28ca 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,10 +74,7 @@ jobs: with: workspaces: src-tauri - name: Measure exact-head Rust coverage - run: >- - cargo llvm-cov --manifest-path src-tauri/Cargo.toml - --branch --no-cfg-coverage --no-cfg-coverage-nightly - --json --summary-only --output-path coverage.json + run: cargo llvm-cov --manifest-path src-tauri/Cargo.toml --branch --json --summary-only --output-path coverage.json --no-cfg-coverage --no-cfg-coverage-nightly - name: Build exact-head coverage evidence run: | node --input-type=module <<'NODE' From 05b496774e0ef64712b9b045976e2079eb724535 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 12:09:01 +0900 Subject: [PATCH 54/66] test: catch quoted coverage evidence variables --- src/lib/coverageEvidenceWorkflow.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/lib/coverageEvidenceWorkflow.test.ts b/src/lib/coverageEvidenceWorkflow.test.ts index 18d8beec9..bfa828ca7 100644 --- a/src/lib/coverageEvidenceWorkflow.test.ts +++ b/src/lib/coverageEvidenceWorkflow.test.ts @@ -30,6 +30,15 @@ describe('Test workflow coverage evidence contract', () => { expect(workflow).toContain('--no-cfg-coverage-nightly'); }); + it('passes coverage-evidence paths through the environment inside the quoted heredoc', () => { + expect(workflow).toContain('os.environ["JS_OUTPUT"]'); + expect(workflow).toContain('os.environ["RUST_OUTPUT"]'); + expect(workflow).toContain('os.environ["GITHUB_SHA"]'); + expect(workflow).toContain('os.environ["COVERAGE_REPORT"]'); + expect(workflow).not.toContain('open("${JS_OUTPUT}"'); + expect(workflow).not.toContain('open("${RUST_OUTPUT}"'); + }); + it('uploads fail-closed evidence under the organization contract name', () => { expect(workflow).toContain('name: coverage-evidence'); expect(workflow).toContain('path: coverage-evidence.json'); From 18fc5cbc1730b011259285726fde76bf2697e5fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 12:13:01 +0900 Subject: [PATCH 55/66] revert: remove invalid coverage workflow assumption --- src/lib/coverageEvidenceWorkflow.test.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/lib/coverageEvidenceWorkflow.test.ts b/src/lib/coverageEvidenceWorkflow.test.ts index bfa828ca7..18d8beec9 100644 --- a/src/lib/coverageEvidenceWorkflow.test.ts +++ b/src/lib/coverageEvidenceWorkflow.test.ts @@ -30,15 +30,6 @@ describe('Test workflow coverage evidence contract', () => { expect(workflow).toContain('--no-cfg-coverage-nightly'); }); - it('passes coverage-evidence paths through the environment inside the quoted heredoc', () => { - expect(workflow).toContain('os.environ["JS_OUTPUT"]'); - expect(workflow).toContain('os.environ["RUST_OUTPUT"]'); - expect(workflow).toContain('os.environ["GITHUB_SHA"]'); - expect(workflow).toContain('os.environ["COVERAGE_REPORT"]'); - expect(workflow).not.toContain('open("${JS_OUTPUT}"'); - expect(workflow).not.toContain('open("${RUST_OUTPUT}"'); - }); - it('uploads fail-closed evidence under the organization contract name', () => { expect(workflow).toContain('name: coverage-evidence'); expect(workflow).toContain('path: coverage-evidence.json'); From 84e93787493ebf275228238caf64c0c2bc06cd14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 12:15:14 +0900 Subject: [PATCH 56/66] test: require bounded failed-coverage diagnostics --- src/lib/coverageEvidenceWorkflow.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/lib/coverageEvidenceWorkflow.test.ts b/src/lib/coverageEvidenceWorkflow.test.ts index 18d8beec9..00dffc2c7 100644 --- a/src/lib/coverageEvidenceWorkflow.test.ts +++ b/src/lib/coverageEvidenceWorkflow.test.ts @@ -30,6 +30,17 @@ describe('Test workflow coverage evidence contract', () => { expect(workflow).toContain('--no-cfg-coverage-nightly'); }); + it('preserves bounded exact-head metric diagnostics when the 100% gate fails', () => { + expect(workflow).toContain('coverage-diagnostic.json'); + expect(workflow).toContain('name: coverage-diagnostic-${{ env.HEAD_SHA }}'); + expect(workflow).toContain('path: coverage-diagnostic.json'); + expect(workflow).toContain('if: always()'); + expect(workflow).toContain('regions: totals?.regions ?? null'); + expect(workflow).toContain('branches: totals?.branches ?? null'); + expect(workflow).toContain('functions: totals?.functions ?? null'); + expect(workflow).toContain('lines: totals?.lines ?? null'); + }); + it('uploads fail-closed evidence under the organization contract name', () => { expect(workflow).toContain('name: coverage-evidence'); expect(workflow).toContain('path: coverage-evidence.json'); From c2a381064cf1dd4e6d49b0d7a472fc42b63a2b97 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 12:16:38 +0900 Subject: [PATCH 57/66] ci: preserve bounded coverage RCA evidence --- .github/workflows/test.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 295bb28ca..e937b8444 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -88,6 +88,20 @@ jobs: const report = JSON.parse(readFileSync('coverage.json', 'utf8')); const totals = report?.data?.[0]?.totals; + const diagnostic = { + schema_version: 1, + head_sha: sha, + repository, + regions: totals?.regions ?? null, + branches: totals?.branches ?? null, + functions: totals?.functions ?? null, + lines: totals?.lines ?? null, + }; + writeFileSync( + 'coverage-diagnostic.json', + `${JSON.stringify(diagnostic, null, 2)}\n`, + ); + const metric = (name, value) => { if ( !value || @@ -124,6 +138,13 @@ jobs: `${JSON.stringify(evidence, null, 2)}\n`, ); NODE + - name: Upload bounded coverage diagnostic + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-diagnostic-${{ env.HEAD_SHA }} + path: coverage-diagnostic.json + if-no-files-found: error - name: Upload coverage evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: From f3dc01aeae287bb65ad4101b7b99b1e9d7218517 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 13:20:54 +0900 Subject: [PATCH 58/66] test: execute Podman desktop command boundary --- .../tests/podman_desktop_command_coverage.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src-tauri/tests/podman_desktop_command_coverage.rs diff --git a/src-tauri/tests/podman_desktop_command_coverage.rs b/src-tauri/tests/podman_desktop_command_coverage.rs new file mode 100644 index 000000000..97c30cc68 --- /dev/null +++ b/src-tauri/tests/podman_desktop_command_coverage.rs @@ -0,0 +1,20 @@ +use disksage_lib::podman_desktop::{inspect_podman_reclaim, PODMAN_DESKTOP_SCHEMA_KIND}; + +/// Exercise the production desktop command boundary with the host's read-only Podman probe. +/// +/// The assertions intentionally cover only invariants that hold whether Podman is absent, +/// installed without a machine, or connected to a running machine. This keeps the regression +/// deterministic while proving that the actual command wrapper executes instead of relying only +/// on source-text contracts or the lower-level projection helper. +#[test] +fn desktop_command_executes_the_read_only_probe_boundary() { + let evidence = inspect_podman_reclaim(); + + assert_eq!(evidence.schema_kind, PODMAN_DESKTOP_SCHEMA_KIND); + assert_eq!(evidence.schema_version, 1); + assert_eq!(evidence.physically_reclaimable_bytes, None); + assert_eq!(evidence.assessment_status, "unverified"); + assert!(evidence.notices.iter().any(|notice| { + notice.contains("no prune, remove, machine lifecycle, TRIM, or raw-image mutation") + })); +} From abb23995772a41352612beacd2606660de76a053 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 17:13:43 +0900 Subject: [PATCH 59/66] test: require visible Rust coverage diagnostics --- src/lib/coverageEvidenceWorkflow.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/coverageEvidenceWorkflow.test.ts b/src/lib/coverageEvidenceWorkflow.test.ts index 00dffc2c7..22a926e58 100644 --- a/src/lib/coverageEvidenceWorkflow.test.ts +++ b/src/lib/coverageEvidenceWorkflow.test.ts @@ -41,6 +41,13 @@ describe('Test workflow coverage evidence contract', () => { expect(workflow).toContain('lines: totals?.lines ?? null'); }); + it('surfaces the same bounded diagnostic in logs and the GitHub step summary', () => { + expect(workflow).toContain("console.error(`coverage-diagnostic=${JSON.stringify(diagnostic)}`)"); + expect(workflow).toContain('process.env.GITHUB_STEP_SUMMARY'); + expect(workflow).toContain('appendFileSync(summaryPath'); + expect(workflow).toContain('Coverage diagnostic for `${sha}`'); + }); + it('uploads fail-closed evidence under the organization contract name', () => { expect(workflow).toContain('name: coverage-evidence'); expect(workflow).toContain('path: coverage-evidence.json'); @@ -50,4 +57,4 @@ describe('Test workflow coverage evidence contract', () => { expect(workflow).toContain('function_coverage'); expect(workflow).toContain('line_coverage'); }); -}); \ No newline at end of file +}); From 5c575864d61bcf3383eb79e8799433b805276db1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 17:14:49 +0900 Subject: [PATCH 60/66] ci: surface exact Rust coverage diagnostics --- .github/workflows/test.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e937b8444..cb5a3662f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,7 +78,7 @@ jobs: - name: Build exact-head coverage evidence run: | node --input-type=module <<'NODE' - import { readFileSync, writeFileSync } from 'node:fs'; + import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'; const sha = process.env.HEAD_SHA ?? ''; const repository = process.env.GITHUB_REPOSITORY ?? ''; @@ -101,6 +101,14 @@ jobs: 'coverage-diagnostic.json', `${JSON.stringify(diagnostic, null, 2)}\n`, ); + console.error(`coverage-diagnostic=${JSON.stringify(diagnostic)}`); + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (summaryPath) { + appendFileSync( + summaryPath, + `### Coverage diagnostic for \`${sha}\`\n\n\`\`\`json\n${JSON.stringify(diagnostic, null, 2)}\n\`\`\`\n`, + ); + } const metric = (name, value) => { if ( From 22427368cc908285025cfe3667196dcec95795c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 17:15:34 +0900 Subject: [PATCH 61/66] docs: explain exact coverage diagnostics --- docs/development/coverage-evidence.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/development/coverage-evidence.md b/docs/development/coverage-evidence.md index eaeacda83..3f7867384 100644 --- a/docs/development/coverage-evidence.md +++ b/docs/development/coverage-evidence.md @@ -12,6 +12,8 @@ The evidence builder rejects a missing or malformed SHA. Both `head_sha` and `co The workflow uses `cargo llvm-cov` with LLVM source-based instrumentation. Branch coverage is requested explicitly with `--branch`; because cargo-llvm-cov documents branch coverage as unstable, the workflow uses an immutable dated Rust nightly with `llvm-tools-preview` instead of silently falling back to a toolchain that cannot measure the required metric. +The workflow passes `--no-cfg-coverage` and `--no-cfg-coverage-nightly`, so cargo-llvm-cov does not define its normal `cfg(coverage)` or `cfg(coverage_nightly)` build configurations. Production code guarded by `#[cfg(not(coverage))]` therefore remains in the measured graph rather than disappearing merely because coverage is being collected. This keeps the gate aligned with the production-behavior requirement; it also means unreachable GUI or command boundaries must be made realistically testable rather than hidden from measurement. + The JSON summary is the only source for the emitted percentages. The evidence builder reads LLVM's aggregate totals and requires all of the following to be present, finite, non-empty, fully covered, and exactly 100%: - statement coverage: LLVM region coverage, used as the statement-equivalent source-based metric; @@ -19,17 +21,19 @@ The JSON summary is the only source for the emitted percentages. The evidence bu - function coverage: LLVM function totals; and - line coverage: LLVM line totals. -The workflow never manufactures a percentage from a successful test exit status. Missing totals, zero denominators, partial coverage, malformed JSON, or identity drift stop the job before the artifact can be uploaded. +The workflow never manufactures a percentage from a successful test exit status. Missing totals, zero denominators, partial coverage, malformed JSON, or identity drift stop the job before the success artifact can be uploaded. -## Evidence contract +## Evidence and failure diagnostics A valid `coverage-evidence.json` has schema version `1` and records the immutable head, repository, CI trust tier, server, workflow name, coverage command, four exact percentages, and `passed: true`. The organization review workflow independently downloads this artifact from the successful `Test` run for the same head and revalidates the contract. -The artifact is uploaded with `if-no-files-found: error`. GitHub Actions artifacts persist workflow outputs such as test and coverage results after the producing job completes, which lets the organization-level reviewer consume evidence without granting the coverage job repository-write permission. +The success artifact is uploaded with `if-no-files-found: error`. GitHub Actions artifacts persist workflow outputs such as test and coverage results after the producing job completes, which lets the organization-level reviewer consume evidence without granting the coverage job repository-write permission. + +When any metric is below 100%, the job still writes the bounded `coverage-diagnostic.json` containing only the exact head/repository identity and aggregate region, branch, function, and line totals. The same bounded diagnostic is emitted to the job log and `GITHUB_STEP_SUMMARY` before validation throws, then uploaded with `if: always()`. This makes the first failing coverage boundary directly observable without exposing source contents, local paths, secrets, test fixtures, or command output, while the success-only `coverage-evidence.json` remains fail closed. ## Fail-closed operating rule -A missing `coverage-evidence` artifact is not equivalent to passing coverage. A queued, cancelled, failed, stale-head, malformed, or less-than-100% measurement is also not passing. Engineers must add realistic tests or remove genuinely unreachable production code; they must not lower thresholds, hard-code percentages, exclude reachable production arithmetic merely to satisfy the gate, or reuse an artifact from an older head. +A missing `coverage-evidence` artifact is not equivalent to passing coverage. A queued, cancelled, failed, stale-head, malformed, or less-than-100% measurement is also not passing. Engineers must add realistic tests or remove genuinely unreachable production code; they must not lower thresholds, hard-code percentages, exclude reachable production behavior merely to satisfy the gate, or reuse an artifact from an older head. ## References From 8cbb057666736b7cff56435960b142f42e6ef181 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 17:15:56 +0900 Subject: [PATCH 62/66] chore: record coverage diagnostic observability --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7391ebb72..d0b572147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Changed - Require the `Test` workflow to produce exact-head, branch-aware, fail-closed Rust coverage evidence from real `cargo llvm-cov` measurements before organization-level review can treat 100% statement-equivalent region, branch, function, and line coverage as passing. +- Surface the same bounded exact-head Rust coverage totals in the failing job log and GitHub step summary before enforcing the 100% gate, while retaining the success-only coverage evidence artifact and privacy-safe diagnostic artifact boundary. - Require a fresh, exact, human-attributed approval and rationale for cloud copy-only and existing-copy adoption actions, with a 15-minute authorization lifetime bound to the candidate, destination, provider, account scope, and review fingerprint. - Return the candidate-specific cloud copy approval action, exact confirmation phrase, and maximum approval age from the Rust plan contract; the frontend only displays and submits that backend-authored phrase and fails closed when it is missing or does not match the candidate action. - Align the frontend toolchain on Vite 8.2 and `@sveltejs/vite-plugin-svelte` 7.2 so the declared peer dependency graph is installable and reproducible. From a53f4e1fea00e1e53b4fd8c765bf456715b3976b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 21:13:00 +0900 Subject: [PATCH 63/66] test: tolerate multiline coverage summary call --- src/lib/coverageEvidenceWorkflow.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/coverageEvidenceWorkflow.test.ts b/src/lib/coverageEvidenceWorkflow.test.ts index 22a926e58..3f93e8792 100644 --- a/src/lib/coverageEvidenceWorkflow.test.ts +++ b/src/lib/coverageEvidenceWorkflow.test.ts @@ -44,7 +44,7 @@ describe('Test workflow coverage evidence contract', () => { it('surfaces the same bounded diagnostic in logs and the GitHub step summary', () => { expect(workflow).toContain("console.error(`coverage-diagnostic=${JSON.stringify(diagnostic)}`)"); expect(workflow).toContain('process.env.GITHUB_STEP_SUMMARY'); - expect(workflow).toContain('appendFileSync(summaryPath'); + expect(workflow).toMatch(/appendFileSync\(\s*summaryPath,/u); expect(workflow).toContain('Coverage diagnostic for `${sha}`'); }); From 7d69ffdc738571d1b7738b43cd01af0d5f77b316 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 21:16:21 +0900 Subject: [PATCH 64/66] test: match escaped coverage summary source --- src/lib/coverageEvidenceWorkflow.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/coverageEvidenceWorkflow.test.ts b/src/lib/coverageEvidenceWorkflow.test.ts index 3f93e8792..9984268af 100644 --- a/src/lib/coverageEvidenceWorkflow.test.ts +++ b/src/lib/coverageEvidenceWorkflow.test.ts @@ -45,7 +45,7 @@ describe('Test workflow coverage evidence contract', () => { expect(workflow).toContain("console.error(`coverage-diagnostic=${JSON.stringify(diagnostic)}`)"); expect(workflow).toContain('process.env.GITHUB_STEP_SUMMARY'); expect(workflow).toMatch(/appendFileSync\(\s*summaryPath,/u); - expect(workflow).toContain('Coverage diagnostic for `${sha}`'); + expect(workflow).toContain('Coverage diagnostic for \\`${sha}\\`'); }); it('uploads fail-closed evidence under the organization contract name', () => { From 443583845546b91b064d3fbcf8501c9f033239f8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 23:39:50 +0900 Subject: [PATCH 65/66] test: require actionable Rust coverage gap diagnostics --- src/lib/coverageEvidenceWorkflow.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/lib/coverageEvidenceWorkflow.test.ts b/src/lib/coverageEvidenceWorkflow.test.ts index 9984268af..6b8a44944 100644 --- a/src/lib/coverageEvidenceWorkflow.test.ts +++ b/src/lib/coverageEvidenceWorkflow.test.ts @@ -41,6 +41,17 @@ describe('Test workflow coverage evidence contract', () => { expect(workflow).toContain('lines: totals?.lines ?? null'); }); + it('identifies the largest exact-head Rust coverage gaps without leaking runner paths', () => { + expect(workflow).toContain('top_uncovered_files'); + expect(workflow).toContain("const marker = '/src-tauri/'"); + expect(workflow).toContain("return `src-tauri/${normalized.slice(markerIndex + marker.length)}`"); + expect(workflow).toContain('.slice(0, 20)'); + expect(workflow).toContain('uncovered_regions'); + expect(workflow).toContain('uncovered_branches'); + expect(workflow).toContain('uncovered_functions'); + expect(workflow).toContain('uncovered_lines'); + }); + it('surfaces the same bounded diagnostic in logs and the GitHub step summary', () => { expect(workflow).toContain("console.error(`coverage-diagnostic=${JSON.stringify(diagnostic)}`)"); expect(workflow).toContain('process.env.GITHUB_STEP_SUMMARY'); From 24fe2d4e5190d437e737035d7131372a470cac10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 23:40:29 +0900 Subject: [PATCH 66/66] ci: surface exact Rust coverage gaps without weakening gate --- .github/workflows/test.yml | 48 +++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb5a3662f..ed21672d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -87,7 +87,52 @@ jobs: } const report = JSON.parse(readFileSync('coverage.json', 'utf8')); - const totals = report?.data?.[0]?.totals; + const coverageData = report?.data?.[0]; + const totals = coverageData?.totals; + const safeGap = (value) => { + const count = value?.count; + const covered = value?.covered; + return Number.isSafeInteger(count) && + Number.isSafeInteger(covered) && + count >= covered && + covered >= 0 + ? count - covered + : 0; + }; + const repositoryPath = (filename) => { + const normalized = String(filename ?? '').replaceAll('\\', '/'); + const marker = '/src-tauri/'; + const markerIndex = normalized.lastIndexOf(marker); + if (markerIndex < 0) return null; + return `src-tauri/${normalized.slice(markerIndex + marker.length)}`; + }; + const top_uncovered_files = (coverageData?.files ?? []) + .map((file) => { + const path = repositoryPath(file?.filename); + const summary = file?.summary; + if (!path) return null; + return { + path, + uncovered_regions: safeGap(summary?.regions), + uncovered_branches: safeGap(summary?.branches), + uncovered_functions: safeGap(summary?.functions), + uncovered_lines: safeGap(summary?.lines), + }; + }) + .filter((entry) => entry && ( + entry.uncovered_regions > 0 || + entry.uncovered_branches > 0 || + entry.uncovered_functions > 0 || + entry.uncovered_lines > 0 + )) + .sort((left, right) => { + const leftGap = left.uncovered_regions + left.uncovered_branches + + left.uncovered_functions + left.uncovered_lines; + const rightGap = right.uncovered_regions + right.uncovered_branches + + right.uncovered_functions + right.uncovered_lines; + return rightGap - leftGap || left.path.localeCompare(right.path); + }) + .slice(0, 20); const diagnostic = { schema_version: 1, head_sha: sha, @@ -96,6 +141,7 @@ jobs: branches: totals?.branches ?? null, functions: totals?.functions ?? null, lines: totals?.lines ?? null, + top_uncovered_files, }; writeFileSync( 'coverage-diagnostic.json',