Skip to main content

rustant_tools/
privacy_manager.rs

1//! Privacy manager tool — data sovereignty, boundary management, access auditing,
2//! and data export/deletion for the `.rustant/` state directory.
3
4use async_trait::async_trait;
5use chrono::{DateTime, Utc};
6use rustant_core::error::ToolError;
7use rustant_core::types::{RiskLevel, ToolOutput};
8use serde::{Deserialize, Serialize};
9use serde_json::{Value, json};
10use std::path::PathBuf;
11use std::time::Duration;
12
13use crate::registry::Tool;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16enum BoundaryType {
17    LocalOnly,
18    Encrypted,
19    Shareable,
20}
21
22impl BoundaryType {
23    fn from_str(s: &str) -> Option<Self> {
24        match s {
25            "local_only" => Some(BoundaryType::LocalOnly),
26            "encrypted" => Some(BoundaryType::Encrypted),
27            "shareable" => Some(BoundaryType::Shareable),
28            _ => None,
29        }
30    }
31
32    fn as_str(&self) -> &str {
33        match self {
34            BoundaryType::LocalOnly => "local_only",
35            BoundaryType::Encrypted => "encrypted",
36            BoundaryType::Shareable => "shareable",
37        }
38    }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42struct DataBoundary {
43    id: usize,
44    name: String,
45    boundary_type: BoundaryType,
46    paths: Vec<String>,
47    description: String,
48    created_at: DateTime<Utc>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52struct AccessLogEntry {
53    timestamp: DateTime<Utc>,
54    tool_name: String,
55    data_accessed: String,
56    purpose: String,
57    boundary_id: Option<usize>,
58}
59
60#[derive(Debug, Serialize, Deserialize)]
61struct PrivacyState {
62    boundaries: Vec<DataBoundary>,
63    access_log: Vec<AccessLogEntry>,
64    next_id: usize,
65    max_log_entries: usize,
66}
67
68impl Default for PrivacyState {
69    fn default() -> Self {
70        Self {
71            boundaries: Vec::new(),
72            access_log: Vec::new(),
73            next_id: 1,
74            max_log_entries: 10_000,
75        }
76    }
77}
78
79pub struct PrivacyManagerTool {
80    workspace: PathBuf,
81}
82
83impl PrivacyManagerTool {
84    pub fn new(workspace: PathBuf) -> Self {
85        Self { workspace }
86    }
87
88    fn state_path(&self) -> PathBuf {
89        self.workspace
90            .join(".rustant")
91            .join("privacy")
92            .join("config.json")
93    }
94
95    fn load_state(&self) -> PrivacyState {
96        let path = self.state_path();
97        if path.exists() {
98            std::fs::read_to_string(&path)
99                .ok()
100                .and_then(|s| serde_json::from_str(&s).ok())
101                .unwrap_or_default()
102        } else {
103            PrivacyState::default()
104        }
105    }
106
107    fn save_state(&self, state: &PrivacyState) -> Result<(), ToolError> {
108        let path = self.state_path();
109        if let Some(parent) = path.parent() {
110            std::fs::create_dir_all(parent).map_err(|e| ToolError::ExecutionFailed {
111                name: "privacy_manager".to_string(),
112                message: format!("Failed to create dir: {}", e),
113            })?;
114        }
115        let json = serde_json::to_string_pretty(state).map_err(|e| ToolError::ExecutionFailed {
116            name: "privacy_manager".to_string(),
117            message: format!("Serialize error: {}", e),
118        })?;
119        let tmp = path.with_extension("json.tmp");
120        std::fs::write(&tmp, &json).map_err(|e| ToolError::ExecutionFailed {
121            name: "privacy_manager".to_string(),
122            message: format!("Write error: {}", e),
123        })?;
124        std::fs::rename(&tmp, &path).map_err(|e| ToolError::ExecutionFailed {
125            name: "privacy_manager".to_string(),
126            message: format!("Rename error: {}", e),
127        })?;
128        Ok(())
129    }
130
131    fn rustant_dir(&self) -> PathBuf {
132        self.workspace.join(".rustant")
133    }
134
135    /// Recursively compute size and file count for a directory.
136    fn dir_stats(&self, path: &std::path::Path) -> (u64, usize) {
137        let mut total_size: u64 = 0;
138        let mut file_count: usize = 0;
139        if let Ok(entries) = std::fs::read_dir(path) {
140            for entry in entries.flatten() {
141                let entry_path = entry.path();
142                if entry_path.is_dir() {
143                    let (s, c) = self.dir_stats(&entry_path);
144                    total_size += s;
145                    file_count += c;
146                } else if entry_path.is_file()
147                    && let Ok(meta) = entry_path.metadata()
148                {
149                    total_size += meta.len();
150                    file_count += 1;
151                }
152            }
153        }
154        (total_size, file_count)
155    }
156
157    /// Collect all top-level subdirectory names under .rustant/.
158    fn list_domains(&self) -> Vec<String> {
159        let rustant_dir = self.rustant_dir();
160        let mut domains = Vec::new();
161        if let Ok(entries) = std::fs::read_dir(&rustant_dir) {
162            for entry in entries.flatten() {
163                if entry.path().is_dir()
164                    && let Some(name) = entry.file_name().to_str()
165                {
166                    domains.push(name.to_string());
167                }
168            }
169        }
170        domains.sort();
171        domains
172    }
173
174    /// Collect all file paths under .rustant/ recursively (relative to .rustant/).
175    fn collect_all_paths(&self) -> Vec<String> {
176        let rustant_dir = self.rustant_dir();
177        let mut paths = Vec::new();
178        self.collect_paths_recursive(&rustant_dir, &rustant_dir, &mut paths);
179        paths
180    }
181
182    fn collect_paths_recursive(
183        &self,
184        base: &std::path::Path,
185        current: &std::path::Path,
186        out: &mut Vec<String>,
187    ) {
188        if let Ok(entries) = std::fs::read_dir(current) {
189            for entry in entries.flatten() {
190                let entry_path = entry.path();
191                if let Ok(rel) = entry_path.strip_prefix(base) {
192                    let rel_str = rel.to_string_lossy().to_string();
193                    out.push(rel_str);
194                }
195                if entry_path.is_dir() {
196                    self.collect_paths_recursive(base, &entry_path, out);
197                }
198            }
199        }
200    }
201
202    /// Check if a given relative path is covered by any boundary.
203    fn path_covered_by_boundary(
204        &self,
205        rel_path: &str,
206        boundaries: &[DataBoundary],
207    ) -> Option<usize> {
208        for boundary in boundaries {
209            for bp in &boundary.paths {
210                if rel_path.starts_with(bp.as_str()) || rel_path == *bp {
211                    return Some(boundary.id);
212                }
213            }
214        }
215        None
216    }
217
218    fn format_size(bytes: u64) -> String {
219        if bytes < 1024 {
220            format!("{} B", bytes)
221        } else if bytes < 1024 * 1024 {
222            format!("{:.1} KB", bytes as f64 / 1024.0)
223        } else {
224            format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
225        }
226    }
227
228    /// Recursively delete contents of a directory (but not the directory itself).
229    fn delete_dir_contents(&self, path: &std::path::Path) -> Result<usize, ToolError> {
230        let mut deleted = 0;
231        if let Ok(entries) = std::fs::read_dir(path) {
232            for entry in entries.flatten() {
233                let entry_path = entry.path();
234                if entry_path.is_dir() {
235                    std::fs::remove_dir_all(&entry_path).map_err(|e| {
236                        ToolError::ExecutionFailed {
237                            name: "privacy_manager".to_string(),
238                            message: format!("Failed to remove {}: {}", entry_path.display(), e),
239                        }
240                    })?;
241                    deleted += 1;
242                } else {
243                    std::fs::remove_file(&entry_path).map_err(|e| ToolError::ExecutionFailed {
244                        name: "privacy_manager".to_string(),
245                        message: format!("Failed to remove {}: {}", entry_path.display(), e),
246                    })?;
247                    deleted += 1;
248                }
249            }
250        }
251        Ok(deleted)
252    }
253
254    // --- Action handlers ---
255
256    fn action_set_boundary(&self, args: &Value) -> Result<ToolOutput, ToolError> {
257        let name = args
258            .get("name")
259            .and_then(|v| v.as_str())
260            .unwrap_or("")
261            .trim();
262        if name.is_empty() {
263            return Ok(ToolOutput::text(
264                "Error: 'name' is required for set_boundary.",
265            ));
266        }
267
268        let boundary_type_str = args
269            .get("boundary_type")
270            .and_then(|v| v.as_str())
271            .unwrap_or("");
272        let boundary_type = match BoundaryType::from_str(boundary_type_str) {
273            Some(bt) => bt,
274            None => {
275                return Ok(ToolOutput::text(format!(
276                    "Error: invalid boundary_type '{}'. Use: local_only, encrypted, shareable",
277                    boundary_type_str
278                )));
279            }
280        };
281
282        let paths: Vec<String> = match args.get("paths") {
283            Some(Value::Array(arr)) => arr
284                .iter()
285                .filter_map(|v| v.as_str().map(|s| s.to_string()))
286                .collect(),
287            _ => {
288                return Ok(ToolOutput::text(
289                    "Error: 'paths' is required as an array of strings.",
290                ));
291            }
292        };
293        if paths.is_empty() {
294            return Ok(ToolOutput::text(
295                "Error: 'paths' must contain at least one path.",
296            ));
297        }
298
299        let description = args
300            .get("description")
301            .and_then(|v| v.as_str())
302            .unwrap_or("")
303            .to_string();
304
305        let mut state = self.load_state();
306        let id = state.next_id;
307        state.next_id += 1;
308
309        state.boundaries.push(DataBoundary {
310            id,
311            name: name.to_string(),
312            boundary_type,
313            paths: paths.clone(),
314            description,
315            created_at: Utc::now(),
316        });
317        self.save_state(&state)?;
318
319        Ok(ToolOutput::text(format!(
320            "Created data boundary #{} '{}' ({}) covering {} path(s).",
321            id,
322            name,
323            boundary_type_str,
324            paths.len()
325        )))
326    }
327
328    fn action_list_boundaries(&self) -> Result<ToolOutput, ToolError> {
329        let state = self.load_state();
330        if state.boundaries.is_empty() {
331            return Ok(ToolOutput::text("No data boundaries defined."));
332        }
333
334        let mut lines = Vec::new();
335        lines.push(format!("Data boundaries ({}):", state.boundaries.len()));
336        for b in &state.boundaries {
337            lines.push(format!(
338                "  #{} — {} [{}]",
339                b.id,
340                b.name,
341                b.boundary_type.as_str()
342            ));
343            for p in &b.paths {
344                lines.push(format!("       path: {}", p));
345            }
346            if !b.description.is_empty() {
347                lines.push(format!("       desc: {}", b.description));
348            }
349        }
350        Ok(ToolOutput::text(lines.join("\n")))
351    }
352
353    fn action_audit_access(&self, args: &Value) -> Result<ToolOutput, ToolError> {
354        let state = self.load_state();
355        let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
356        let tool_filter = args.get("tool_name").and_then(|v| v.as_str());
357        let boundary_filter = args
358            .get("boundary_id")
359            .and_then(|v| v.as_u64())
360            .map(|v| v as usize);
361
362        let filtered: Vec<&AccessLogEntry> = state
363            .access_log
364            .iter()
365            .rev()
366            .filter(|e| {
367                if let Some(tn) = tool_filter
368                    && e.tool_name != tn
369                {
370                    return false;
371                }
372                if let Some(bid) = boundary_filter
373                    && e.boundary_id != Some(bid)
374                {
375                    return false;
376                }
377                true
378            })
379            .take(limit)
380            .collect();
381
382        if filtered.is_empty() {
383            return Ok(ToolOutput::text("No access log entries found."));
384        }
385
386        let mut lines = Vec::new();
387        lines.push(format!("Access log ({} entries shown):", filtered.len()));
388        for entry in &filtered {
389            let boundary_note = if let Some(bid) = entry.boundary_id {
390                format!(" [boundary #{}]", bid)
391            } else {
392                String::new()
393            };
394            lines.push(format!(
395                "  {} — {} accessed '{}' for '{}'{}",
396                entry.timestamp.format("%Y-%m-%d %H:%M:%S"),
397                entry.tool_name,
398                entry.data_accessed,
399                entry.purpose,
400                boundary_note
401            ));
402        }
403        Ok(ToolOutput::text(lines.join("\n")))
404    }
405
406    fn action_compliance_check(&self) -> Result<ToolOutput, ToolError> {
407        let state = self.load_state();
408        let rustant_dir = self.rustant_dir();
409        if !rustant_dir.exists() {
410            return Ok(ToolOutput::text(
411                "No .rustant/ directory found. Nothing to check.",
412            ));
413        }
414
415        let domains = self.list_domains();
416        if domains.is_empty() {
417            return Ok(ToolOutput::text(
418                "No data directories found in .rustant/. Compliance check complete — nothing to cover.",
419            ));
420        }
421
422        let all_paths = self.collect_all_paths();
423        let mut covered_count = 0;
424        let mut uncovered_dirs: Vec<String> = Vec::new();
425
426        for domain in &domains {
427            if self
428                .path_covered_by_boundary(domain, &state.boundaries)
429                .is_some()
430            {
431                covered_count += 1;
432            } else {
433                uncovered_dirs.push(domain.clone());
434            }
435        }
436
437        let total = domains.len();
438        let coverage_pct = if total > 0 {
439            (covered_count as f64 / total as f64) * 100.0
440        } else {
441            100.0
442        };
443
444        let mut lines = Vec::new();
445        lines.push("Compliance Check Report".to_string());
446        lines.push(format!("  Total directories: {}", total));
447        lines.push(format!("  Covered by boundaries: {}", covered_count));
448        lines.push(format!("  Coverage: {:.0}%", coverage_pct));
449        lines.push(format!("  Total paths scanned: {}", all_paths.len()));
450
451        if !uncovered_dirs.is_empty() {
452            lines.push(String::new());
453            lines.push("  Uncovered directories:".to_string());
454            for d in &uncovered_dirs {
455                lines.push(format!("    - {}", d));
456            }
457            lines.push(String::new());
458            lines
459                .push("  Recommendation: Create boundaries for uncovered directories.".to_string());
460        } else {
461            lines.push(String::new());
462            lines.push("  All directories are covered by boundaries.".to_string());
463        }
464
465        Ok(ToolOutput::text(lines.join("\n")))
466    }
467
468    fn action_export_data(&self, args: &Value) -> Result<ToolOutput, ToolError> {
469        let output_name = args
470            .get("output")
471            .and_then(|v| v.as_str())
472            .unwrap_or("rustant_export.json");
473
474        let rustant_dir = self.rustant_dir();
475        if !rustant_dir.exists() {
476            return Ok(ToolOutput::text(
477                "No .rustant/ directory found. Nothing to export.",
478            ));
479        }
480
481        let mut export = serde_json::Map::new();
482        let domains = self.list_domains();
483
484        for domain in &domains {
485            let domain_dir = rustant_dir.join(domain);
486            let mut domain_files = serde_json::Map::new();
487
488            if let Ok(entries) = std::fs::read_dir(&domain_dir) {
489                for entry in entries.flatten() {
490                    let entry_path = entry.path();
491                    if entry_path.is_file()
492                        && let Some(fname) = entry_path.file_name().and_then(|f| f.to_str())
493                    {
494                        match std::fs::read_to_string(&entry_path) {
495                            Ok(content) => {
496                                // Try to parse as JSON; if it fails, store as string
497                                if let Ok(val) = serde_json::from_str::<Value>(&content) {
498                                    domain_files.insert(fname.to_string(), val);
499                                } else {
500                                    domain_files.insert(fname.to_string(), Value::String(content));
501                                }
502                            }
503                            Err(_) => {
504                                domain_files.insert(
505                                    fname.to_string(),
506                                    Value::String("[binary or unreadable]".to_string()),
507                                );
508                            }
509                        }
510                    }
511                }
512            }
513            export.insert(domain.clone(), Value::Object(domain_files));
514        }
515
516        let export_json =
517            serde_json::to_string_pretty(&export).map_err(|e| ToolError::ExecutionFailed {
518                name: "privacy_manager".to_string(),
519                message: format!("Failed to serialize export: {}", e),
520            })?;
521
522        // If small enough, return inline; otherwise write to file
523        if export_json.len() < 50_000 {
524            Ok(ToolOutput::text(format!(
525                "Exported {} domain(s) ({} bytes):\n{}",
526                domains.len(),
527                export_json.len(),
528                export_json
529            )))
530        } else {
531            let output_path = self.workspace.join(output_name);
532            std::fs::write(&output_path, &export_json).map_err(|e| ToolError::ExecutionFailed {
533                name: "privacy_manager".to_string(),
534                message: format!("Failed to write export file: {}", e),
535            })?;
536            Ok(ToolOutput::text(format!(
537                "Exported {} domain(s) to {}. Size: {}",
538                domains.len(),
539                output_path.display(),
540                Self::format_size(export_json.len() as u64)
541            )))
542        }
543    }
544
545    fn action_delete_data(&self, args: &Value) -> Result<ToolOutput, ToolError> {
546        let domain = args
547            .get("domain")
548            .and_then(|v| v.as_str())
549            .unwrap_or("")
550            .trim();
551        if domain.is_empty() {
552            return Ok(ToolOutput::text(
553                "Error: 'domain' is required for delete_data. Use a domain name or 'all'.",
554            ));
555        }
556
557        let rustant_dir = self.rustant_dir();
558        if !rustant_dir.exists() {
559            return Ok(ToolOutput::text("No .rustant/ directory found."));
560        }
561
562        if domain == "all" {
563            let mut deleted_total = 0;
564            let mut deleted_domains = Vec::new();
565            if let Ok(entries) = std::fs::read_dir(&rustant_dir) {
566                for entry in entries.flatten() {
567                    let entry_path = entry.path();
568                    if entry_path.is_dir() {
569                        let dir_name = entry.file_name().to_str().unwrap_or("").to_string();
570                        // Preserve the privacy config directory itself
571                        if dir_name == "privacy" {
572                            continue;
573                        }
574                        let count = self.delete_dir_contents(&entry_path)?;
575                        std::fs::remove_dir_all(&entry_path).map_err(|e| {
576                            ToolError::ExecutionFailed {
577                                name: "privacy_manager".to_string(),
578                                message: format!("Failed to remove dir {}: {}", dir_name, e),
579                            }
580                        })?;
581                        deleted_total += count + 1; // +1 for the dir itself
582                        deleted_domains.push(dir_name);
583                    } else if entry_path.is_file() {
584                        // Don't delete files at top level that aren't in privacy/
585                        let fname = entry.file_name().to_str().unwrap_or("").to_string();
586                        std::fs::remove_file(&entry_path).map_err(|e| {
587                            ToolError::ExecutionFailed {
588                                name: "privacy_manager".to_string(),
589                                message: format!("Failed to remove file {}: {}", fname, e),
590                            }
591                        })?;
592                        deleted_total += 1;
593                    }
594                }
595            }
596            Ok(ToolOutput::text(format!(
597                "Deleted all data except privacy config. Removed {} item(s) across domain(s): {}",
598                deleted_total,
599                if deleted_domains.is_empty() {
600                    "none".to_string()
601                } else {
602                    deleted_domains.join(", ")
603                }
604            )))
605        } else {
606            let domain_dir = rustant_dir.join(domain);
607            if !domain_dir.exists() || !domain_dir.is_dir() {
608                return Ok(ToolOutput::text(format!(
609                    "Domain '{}' not found in .rustant/.",
610                    domain
611                )));
612            }
613            let count = self.delete_dir_contents(&domain_dir)?;
614            std::fs::remove_dir_all(&domain_dir).map_err(|e| ToolError::ExecutionFailed {
615                name: "privacy_manager".to_string(),
616                message: format!("Failed to remove domain dir: {}", e),
617            })?;
618            Ok(ToolOutput::text(format!(
619                "Deleted domain '{}': removed {} item(s).",
620                domain, count
621            )))
622        }
623    }
624
625    fn action_encrypt_store(&self, args: &Value) -> Result<ToolOutput, ToolError> {
626        let path_str = args
627            .get("path")
628            .and_then(|v| v.as_str())
629            .unwrap_or("")
630            .trim()
631            .to_string();
632        if path_str.is_empty() {
633            return Ok(ToolOutput::text(
634                "Error: 'path' is required for encrypt_store.",
635            ));
636        }
637
638        // Resolve path relative to .rustant/ if not absolute
639        let file_path = if std::path::Path::new(&path_str).is_absolute() {
640            PathBuf::from(&path_str)
641        } else {
642            self.rustant_dir().join(&path_str)
643        };
644
645        if !file_path.exists() || !file_path.is_file() {
646            return Ok(ToolOutput::text(format!(
647                "Error: file '{}' not found.",
648                file_path.display()
649            )));
650        }
651
652        let content = std::fs::read(&file_path).map_err(|e| ToolError::ExecutionFailed {
653            name: "privacy_manager".to_string(),
654            message: format!("Failed to read file: {}", e),
655        })?;
656
657        let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &content);
658
659        let encrypted_path = file_path.with_extension(format!(
660            "{}.encrypted",
661            file_path
662                .extension()
663                .and_then(|e| e.to_str())
664                .unwrap_or("dat")
665        ));
666
667        let output_content = format!(
668            "# Rustant encrypted store (base64 placeholder)\n\
669             # TODO: Replace with AES-256-GCM encryption (future crate)\n\
670             # Original: {}\n\
671             # Encrypted at: {}\n\
672             {}\n",
673            file_path.display(),
674            Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
675            encoded
676        );
677
678        std::fs::write(&encrypted_path, output_content.as_bytes()).map_err(|e| {
679            ToolError::ExecutionFailed {
680                name: "privacy_manager".to_string(),
681                message: format!("Failed to write encrypted file: {}", e),
682            }
683        })?;
684
685        Ok(ToolOutput::text(format!(
686            "Encrypted (base64) '{}' -> '{}'. Note: this is a base64 placeholder; \
687             AES-256-GCM encryption will be added in a future release.",
688            file_path.display(),
689            encrypted_path.display()
690        )))
691    }
692
693    fn action_privacy_report(&self) -> Result<ToolOutput, ToolError> {
694        let state = self.load_state();
695        let rustant_dir = self.rustant_dir();
696        if !rustant_dir.exists() {
697            return Ok(ToolOutput::text(
698                "No .rustant/ directory found. Nothing to report.",
699            ));
700        }
701
702        let (total_size, total_files) = self.dir_stats(&rustant_dir);
703        let domains = self.list_domains();
704
705        let mut lines = Vec::new();
706        lines.push("Privacy Report".to_string());
707        lines.push("==============".to_string());
708        lines.push(String::new());
709        lines.push(format!(
710            "Total data size: {} ({} files)",
711            Self::format_size(total_size),
712            total_files
713        ));
714        lines.push(format!("Domains: {}", domains.len()));
715
716        // Breakdown by domain
717        if !domains.is_empty() {
718            lines.push(String::new());
719            lines.push("Domain breakdown:".to_string());
720            for domain in &domains {
721                let domain_dir = rustant_dir.join(domain);
722                let (size, count) = self.dir_stats(&domain_dir);
723                let covered = self
724                    .path_covered_by_boundary(domain, &state.boundaries)
725                    .is_some();
726                let coverage_tag = if covered {
727                    " [covered]"
728                } else {
729                    " [uncovered]"
730                };
731                lines.push(format!(
732                    "  {} — {} ({} files){}",
733                    domain,
734                    Self::format_size(size),
735                    count,
736                    coverage_tag
737                ));
738            }
739        }
740
741        // Boundary coverage
742        let covered_count = domains
743            .iter()
744            .filter(|d| {
745                self.path_covered_by_boundary(d, &state.boundaries)
746                    .is_some()
747            })
748            .count();
749        let coverage_pct = if domains.is_empty() {
750            100.0
751        } else {
752            (covered_count as f64 / domains.len() as f64) * 100.0
753        };
754        lines.push(String::new());
755        lines.push(format!("Boundary coverage: {:.0}%", coverage_pct));
756        lines.push(format!("Boundaries defined: {}", state.boundaries.len()));
757
758        // Access log stats
759        lines.push(String::new());
760        lines.push("Access log:".to_string());
761        lines.push(format!("  Total entries: {}", state.access_log.len()));
762        let unique_tools: std::collections::HashSet<&str> = state
763            .access_log
764            .iter()
765            .map(|e| e.tool_name.as_str())
766            .collect();
767        lines.push(format!("  Unique tools: {}", unique_tools.len()));
768
769        // Recommendations
770        let uncovered: Vec<&String> = domains
771            .iter()
772            .filter(|d| {
773                self.path_covered_by_boundary(d, &state.boundaries)
774                    .is_none()
775            })
776            .collect();
777        if !uncovered.is_empty() {
778            lines.push(String::new());
779            lines.push("Recommendations:".to_string());
780            for d in &uncovered {
781                lines.push(format!(
782                    "  - Create a boundary for '{}' to control data access",
783                    d
784                ));
785            }
786        }
787
788        Ok(ToolOutput::text(lines.join("\n")))
789    }
790}
791
792#[async_trait]
793impl Tool for PrivacyManagerTool {
794    fn name(&self) -> &str {
795        "privacy_manager"
796    }
797
798    fn description(&self) -> &str {
799        "Privacy and data sovereignty: boundaries, access auditing, data export/deletion. Actions: set_boundary, list_boundaries, audit_access, compliance_check, export_data, delete_data, encrypt_store, privacy_report."
800    }
801
802    fn parameters_schema(&self) -> Value {
803        json!({
804            "type": "object",
805            "properties": {
806                "action": {
807                    "type": "string",
808                    "enum": [
809                        "set_boundary", "list_boundaries", "audit_access",
810                        "compliance_check", "export_data", "delete_data",
811                        "encrypt_store", "privacy_report"
812                    ],
813                    "description": "Action to perform"
814                },
815                "name": {
816                    "type": "string",
817                    "description": "Boundary name (for set_boundary)"
818                },
819                "boundary_type": {
820                    "type": "string",
821                    "enum": ["local_only", "encrypted", "shareable"],
822                    "description": "Boundary type (for set_boundary)"
823                },
824                "paths": {
825                    "type": "array",
826                    "items": { "type": "string" },
827                    "description": "Paths covered by the boundary (for set_boundary)"
828                },
829                "description": {
830                    "type": "string",
831                    "description": "Boundary description (for set_boundary)"
832                },
833                "limit": {
834                    "type": "integer",
835                    "description": "Max entries to return (for audit_access, default 50)"
836                },
837                "tool_name": {
838                    "type": "string",
839                    "description": "Filter by tool name (for audit_access)"
840                },
841                "boundary_id": {
842                    "type": "integer",
843                    "description": "Filter by boundary ID (for audit_access)"
844                },
845                "output": {
846                    "type": "string",
847                    "description": "Output filename (for export_data, default rustant_export.json)"
848                },
849                "domain": {
850                    "type": "string",
851                    "description": "Domain name or 'all' (for delete_data)"
852                },
853                "path": {
854                    "type": "string",
855                    "description": "File path to encrypt (for encrypt_store)"
856                }
857            },
858            "required": ["action"]
859        })
860    }
861
862    fn risk_level(&self) -> RiskLevel {
863        RiskLevel::Write
864    }
865
866    fn timeout(&self) -> Duration {
867        Duration::from_secs(60)
868    }
869
870    async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
871        let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
872
873        match action {
874            "set_boundary" => self.action_set_boundary(&args),
875            "list_boundaries" => self.action_list_boundaries(),
876            "audit_access" => self.action_audit_access(&args),
877            "compliance_check" => self.action_compliance_check(),
878            "export_data" => self.action_export_data(&args),
879            "delete_data" => self.action_delete_data(&args),
880            "encrypt_store" => self.action_encrypt_store(&args),
881            "privacy_report" => self.action_privacy_report(),
882            _ => Ok(ToolOutput::text(format!(
883                "Unknown action: '{}'. Use: set_boundary, list_boundaries, audit_access, \
884                 compliance_check, export_data, delete_data, encrypt_store, privacy_report",
885                action
886            ))),
887        }
888    }
889}
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894    use tempfile::TempDir;
895
896    fn setup() -> (TempDir, PathBuf) {
897        let dir = TempDir::new().unwrap();
898        let workspace = dir.path().canonicalize().unwrap();
899        (dir, workspace)
900    }
901
902    #[test]
903    fn test_tool_properties() {
904        let (_dir, workspace) = setup();
905        let tool = PrivacyManagerTool::new(workspace);
906        assert_eq!(tool.name(), "privacy_manager");
907        assert!(tool.description().contains("Privacy"));
908        assert_eq!(tool.risk_level(), RiskLevel::Write);
909        assert_eq!(tool.timeout(), Duration::from_secs(60));
910    }
911
912    #[test]
913    fn test_schema_validation() {
914        let (_dir, workspace) = setup();
915        let tool = PrivacyManagerTool::new(workspace);
916        let schema = tool.parameters_schema();
917        assert!(schema.get("properties").is_some());
918        let props = schema.get("properties").unwrap();
919        assert!(props.get("action").is_some());
920        assert!(props.get("name").is_some());
921        assert!(props.get("boundary_type").is_some());
922        assert!(props.get("paths").is_some());
923        assert!(props.get("domain").is_some());
924        assert!(props.get("path").is_some());
925        let action_enum = props["action"]["enum"].as_array().unwrap();
926        assert_eq!(action_enum.len(), 8);
927        let required = schema["required"].as_array().unwrap();
928        assert_eq!(required.len(), 1);
929        assert_eq!(required[0], "action");
930    }
931
932    #[tokio::test]
933    async fn test_set_boundary() {
934        let (_dir, workspace) = setup();
935        let tool = PrivacyManagerTool::new(workspace);
936
937        let result = tool
938            .execute(json!({
939                "action": "set_boundary",
940                "name": "personal_data",
941                "boundary_type": "local_only",
942                "paths": ["inbox", "relationships"],
943                "description": "Personal data stays local"
944            }))
945            .await
946            .unwrap();
947        assert!(result.content.contains("#1"));
948        assert!(result.content.contains("personal_data"));
949        assert!(result.content.contains("2 path(s)"));
950
951        // Verify it shows up in list
952        let list = tool
953            .execute(json!({"action": "list_boundaries"}))
954            .await
955            .unwrap();
956        assert!(list.content.contains("personal_data"));
957        assert!(list.content.contains("local_only"));
958        assert!(list.content.contains("inbox"));
959        assert!(list.content.contains("relationships"));
960    }
961
962    #[tokio::test]
963    async fn test_list_boundaries_empty() {
964        let (_dir, workspace) = setup();
965        let tool = PrivacyManagerTool::new(workspace);
966
967        let result = tool
968            .execute(json!({"action": "list_boundaries"}))
969            .await
970            .unwrap();
971        assert!(result.content.contains("No data boundaries"));
972    }
973
974    #[tokio::test]
975    async fn test_audit_access_empty() {
976        let (_dir, workspace) = setup();
977        let tool = PrivacyManagerTool::new(workspace);
978
979        let result = tool
980            .execute(json!({"action": "audit_access"}))
981            .await
982            .unwrap();
983        assert!(result.content.contains("No access log entries"));
984    }
985
986    #[tokio::test]
987    async fn test_compliance_check_no_data() {
988        let (_dir, workspace) = setup();
989        let tool = PrivacyManagerTool::new(workspace.clone());
990
991        // Create empty .rustant/ dir
992        std::fs::create_dir_all(workspace.join(".rustant")).unwrap();
993
994        let result = tool
995            .execute(json!({"action": "compliance_check"}))
996            .await
997            .unwrap();
998        assert!(
999            result.content.contains("Nothing to cover")
1000                || result.content.contains("nothing to cover")
1001        );
1002    }
1003
1004    #[tokio::test]
1005    async fn test_compliance_check_with_boundary() {
1006        let (_dir, workspace) = setup();
1007        let tool = PrivacyManagerTool::new(workspace.clone());
1008
1009        // Create .rustant/career/ and .rustant/inbox/ directories
1010        std::fs::create_dir_all(workspace.join(".rustant").join("career")).unwrap();
1011        std::fs::create_dir_all(workspace.join(".rustant").join("inbox")).unwrap();
1012
1013        // Create a boundary covering career
1014        tool.execute(json!({
1015            "action": "set_boundary",
1016            "name": "career_data",
1017            "boundary_type": "encrypted",
1018            "paths": ["career"]
1019        }))
1020        .await
1021        .unwrap();
1022
1023        let result = tool
1024            .execute(json!({"action": "compliance_check"}))
1025            .await
1026            .unwrap();
1027        assert!(result.content.contains("Compliance Check Report"));
1028        // career is covered, inbox and privacy are present
1029        // privacy dir is created by save_state, career is covered, inbox is not
1030        assert!(result.content.contains("Uncovered directories"));
1031        assert!(result.content.contains("inbox"));
1032    }
1033
1034    #[tokio::test]
1035    async fn test_delete_data_domain() {
1036        let (_dir, workspace) = setup();
1037        let tool = PrivacyManagerTool::new(workspace.clone());
1038
1039        // Create a test domain with a file
1040        let domain_dir = workspace.join(".rustant").join("test_domain");
1041        std::fs::create_dir_all(&domain_dir).unwrap();
1042        std::fs::write(domain_dir.join("data.json"), r#"{"key": "value"}"#).unwrap();
1043        assert!(domain_dir.join("data.json").exists());
1044
1045        let result = tool
1046            .execute(json!({"action": "delete_data", "domain": "test_domain"}))
1047            .await
1048            .unwrap();
1049        assert!(result.content.contains("Deleted domain 'test_domain'"));
1050        assert!(result.content.contains("removed"));
1051        assert!(!domain_dir.exists());
1052    }
1053
1054    #[tokio::test]
1055    async fn test_export_data() {
1056        let (_dir, workspace) = setup();
1057        let tool = PrivacyManagerTool::new(workspace.clone());
1058
1059        // Create some state files
1060        let career_dir = workspace.join(".rustant").join("career");
1061        std::fs::create_dir_all(&career_dir).unwrap();
1062        std::fs::write(
1063            career_dir.join("goals.json"),
1064            r#"{"goals": ["learn rust"]}"#,
1065        )
1066        .unwrap();
1067
1068        let inbox_dir = workspace.join(".rustant").join("inbox");
1069        std::fs::create_dir_all(&inbox_dir).unwrap();
1070        std::fs::write(inbox_dir.join("items.json"), r#"{"items": []}"#).unwrap();
1071
1072        let result = tool
1073            .execute(json!({"action": "export_data"}))
1074            .await
1075            .unwrap();
1076        assert!(result.content.contains("Exported"));
1077        assert!(result.content.contains("career"));
1078        assert!(result.content.contains("inbox"));
1079        assert!(result.content.contains("learn rust"));
1080    }
1081
1082    #[tokio::test]
1083    async fn test_privacy_report() {
1084        let (_dir, workspace) = setup();
1085        let tool = PrivacyManagerTool::new(workspace.clone());
1086
1087        // Create some domain dirs with data
1088        let career_dir = workspace.join(".rustant").join("career");
1089        std::fs::create_dir_all(&career_dir).unwrap();
1090        std::fs::write(career_dir.join("data.json"), "test data content").unwrap();
1091
1092        let result = tool
1093            .execute(json!({"action": "privacy_report"}))
1094            .await
1095            .unwrap();
1096        assert!(result.content.contains("Privacy Report"));
1097        assert!(result.content.contains("Total data size"));
1098        assert!(result.content.contains("career"));
1099        assert!(result.content.contains("Access log"));
1100        assert!(result.content.contains("Unique tools"));
1101    }
1102
1103    #[tokio::test]
1104    async fn test_boundary_type_validation() {
1105        let (_dir, workspace) = setup();
1106        let tool = PrivacyManagerTool::new(workspace);
1107
1108        let result = tool
1109            .execute(json!({
1110                "action": "set_boundary",
1111                "name": "test",
1112                "boundary_type": "invalid_type",
1113                "paths": ["some_path"]
1114            }))
1115            .await
1116            .unwrap();
1117        assert!(result.content.contains("Error"));
1118        assert!(result.content.contains("invalid boundary_type"));
1119        assert!(result.content.contains("invalid_type"));
1120    }
1121
1122    #[tokio::test]
1123    async fn test_access_log_eviction() {
1124        let (_dir, workspace) = setup();
1125        let tool = PrivacyManagerTool::new(workspace);
1126
1127        // Create state with max_log_entries = 5 for testing
1128        let mut state = PrivacyState {
1129            max_log_entries: 5,
1130            ..Default::default()
1131        };
1132
1133        // Add 7 entries
1134        for i in 0..7 {
1135            state.access_log.push(AccessLogEntry {
1136                timestamp: Utc::now(),
1137                tool_name: format!("tool_{}", i),
1138                data_accessed: format!("path_{}", i),
1139                purpose: "test".to_string(),
1140                boundary_id: None,
1141            });
1142            // Evict oldest if over limit
1143            if state.access_log.len() > state.max_log_entries {
1144                state
1145                    .access_log
1146                    .drain(0..state.access_log.len() - state.max_log_entries);
1147            }
1148        }
1149        tool.save_state(&state).unwrap();
1150
1151        let loaded = tool.load_state();
1152        assert_eq!(loaded.access_log.len(), 5);
1153        // The oldest entries (tool_0, tool_1) should be gone
1154        assert_eq!(loaded.access_log[0].tool_name, "tool_2");
1155        assert_eq!(loaded.access_log[4].tool_name, "tool_6");
1156    }
1157
1158    #[tokio::test]
1159    async fn test_state_roundtrip() {
1160        let (_dir, workspace) = setup();
1161        let tool = PrivacyManagerTool::new(workspace);
1162
1163        // Set up state with boundary and access log entry
1164        let mut state = PrivacyState::default();
1165        state.boundaries.push(DataBoundary {
1166            id: 1,
1167            name: "test_boundary".to_string(),
1168            boundary_type: BoundaryType::Encrypted,
1169            paths: vec!["career".to_string(), "inbox".to_string()],
1170            description: "test description".to_string(),
1171            created_at: Utc::now(),
1172        });
1173        state.access_log.push(AccessLogEntry {
1174            timestamp: Utc::now(),
1175            tool_name: "file_read".to_string(),
1176            data_accessed: "career/goals.json".to_string(),
1177            purpose: "reading goals".to_string(),
1178            boundary_id: Some(1),
1179        });
1180        state.next_id = 2;
1181
1182        tool.save_state(&state).unwrap();
1183        let loaded = tool.load_state();
1184
1185        assert_eq!(loaded.next_id, 2);
1186        assert_eq!(loaded.max_log_entries, 10_000);
1187        assert_eq!(loaded.boundaries.len(), 1);
1188        assert_eq!(loaded.boundaries[0].name, "test_boundary");
1189        assert_eq!(loaded.boundaries[0].boundary_type, BoundaryType::Encrypted);
1190        assert_eq!(loaded.boundaries[0].paths.len(), 2);
1191        assert_eq!(loaded.access_log.len(), 1);
1192        assert_eq!(loaded.access_log[0].tool_name, "file_read");
1193        assert_eq!(loaded.access_log[0].boundary_id, Some(1));
1194    }
1195
1196    #[tokio::test]
1197    async fn test_unknown_action() {
1198        let (_dir, workspace) = setup();
1199        let tool = PrivacyManagerTool::new(workspace);
1200
1201        let result = tool
1202            .execute(json!({"action": "nonexistent"}))
1203            .await
1204            .unwrap();
1205        assert!(result.content.contains("Unknown action"));
1206        assert!(result.content.contains("nonexistent"));
1207    }
1208
1209    #[tokio::test]
1210    async fn test_encrypt_store() {
1211        let (_dir, workspace) = setup();
1212        let tool = PrivacyManagerTool::new(workspace.clone());
1213
1214        // Create a file to encrypt
1215        let data_dir = workspace.join(".rustant").join("career");
1216        std::fs::create_dir_all(&data_dir).unwrap();
1217        let file_path = data_dir.join("goals.json");
1218        std::fs::write(&file_path, r#"{"goals": ["learn rust"]}"#).unwrap();
1219
1220        let result = tool
1221            .execute(json!({
1222                "action": "encrypt_store",
1223                "path": "career/goals.json"
1224            }))
1225            .await
1226            .unwrap();
1227        assert!(result.content.contains("Encrypted"));
1228        assert!(result.content.contains("base64"));
1229
1230        // Check the .encrypted file was created
1231        let encrypted_path = data_dir.join("goals.json.encrypted");
1232        assert!(encrypted_path.exists());
1233        let encrypted_content = std::fs::read_to_string(&encrypted_path).unwrap();
1234        assert!(encrypted_content.contains("base64 placeholder"));
1235        assert!(encrypted_content.contains("AES-256-GCM"));
1236    }
1237
1238    #[tokio::test]
1239    async fn test_set_boundary_missing_name() {
1240        let (_dir, workspace) = setup();
1241        let tool = PrivacyManagerTool::new(workspace);
1242
1243        let result = tool
1244            .execute(json!({
1245                "action": "set_boundary",
1246                "boundary_type": "local_only",
1247                "paths": ["inbox"]
1248            }))
1249            .await
1250            .unwrap();
1251        assert!(result.content.contains("Error"));
1252        assert!(result.content.contains("name"));
1253    }
1254
1255    #[tokio::test]
1256    async fn test_set_boundary_missing_paths() {
1257        let (_dir, workspace) = setup();
1258        let tool = PrivacyManagerTool::new(workspace);
1259
1260        let result = tool
1261            .execute(json!({
1262                "action": "set_boundary",
1263                "name": "test",
1264                "boundary_type": "local_only"
1265            }))
1266            .await
1267            .unwrap();
1268        assert!(result.content.contains("Error"));
1269        assert!(result.content.contains("paths"));
1270    }
1271
1272    #[tokio::test]
1273    async fn test_delete_data_nonexistent_domain() {
1274        let (_dir, workspace) = setup();
1275        let tool = PrivacyManagerTool::new(workspace.clone());
1276        std::fs::create_dir_all(workspace.join(".rustant")).unwrap();
1277
1278        let result = tool
1279            .execute(json!({"action": "delete_data", "domain": "nonexistent"}))
1280            .await
1281            .unwrap();
1282        assert!(result.content.contains("not found"));
1283    }
1284
1285    #[tokio::test]
1286    async fn test_encrypt_store_missing_file() {
1287        let (_dir, workspace) = setup();
1288        let tool = PrivacyManagerTool::new(workspace.clone());
1289        std::fs::create_dir_all(workspace.join(".rustant")).unwrap();
1290
1291        let result = tool
1292            .execute(json!({
1293                "action": "encrypt_store",
1294                "path": "nonexistent/file.json"
1295            }))
1296            .await
1297            .unwrap();
1298        assert!(result.content.contains("Error"));
1299        assert!(result.content.contains("not found"));
1300    }
1301
1302    #[tokio::test]
1303    async fn test_delete_data_all() {
1304        let (_dir, workspace) = setup();
1305        let tool = PrivacyManagerTool::new(workspace.clone());
1306
1307        // Create multiple domains
1308        let career_dir = workspace.join(".rustant").join("career");
1309        std::fs::create_dir_all(&career_dir).unwrap();
1310        std::fs::write(career_dir.join("data.json"), "test").unwrap();
1311
1312        let inbox_dir = workspace.join(".rustant").join("inbox");
1313        std::fs::create_dir_all(&inbox_dir).unwrap();
1314        std::fs::write(inbox_dir.join("items.json"), "items").unwrap();
1315
1316        // Also save privacy state so privacy/ dir exists
1317        tool.execute(json!({
1318            "action": "set_boundary",
1319            "name": "test",
1320            "boundary_type": "shareable",
1321            "paths": ["career"]
1322        }))
1323        .await
1324        .unwrap();
1325
1326        let result = tool
1327            .execute(json!({"action": "delete_data", "domain": "all"}))
1328            .await
1329            .unwrap();
1330        assert!(
1331            result
1332                .content
1333                .contains("Deleted all data except privacy config")
1334        );
1335
1336        // Career and inbox should be gone, privacy should remain
1337        assert!(!career_dir.exists());
1338        assert!(!inbox_dir.exists());
1339        assert!(workspace.join(".rustant").join("privacy").exists());
1340    }
1341
1342    #[tokio::test]
1343    async fn test_audit_access_with_filters() {
1344        let (_dir, workspace) = setup();
1345        let tool = PrivacyManagerTool::new(workspace);
1346
1347        // Manually create state with access log entries
1348        let mut state = PrivacyState::default();
1349        state.access_log.push(AccessLogEntry {
1350            timestamp: Utc::now(),
1351            tool_name: "file_read".to_string(),
1352            data_accessed: "career/goals.json".to_string(),
1353            purpose: "reading".to_string(),
1354            boundary_id: Some(1),
1355        });
1356        state.access_log.push(AccessLogEntry {
1357            timestamp: Utc::now(),
1358            tool_name: "shell_exec".to_string(),
1359            data_accessed: "inbox/items.json".to_string(),
1360            purpose: "listing".to_string(),
1361            boundary_id: None,
1362        });
1363        state.access_log.push(AccessLogEntry {
1364            timestamp: Utc::now(),
1365            tool_name: "file_read".to_string(),
1366            data_accessed: "inbox/archive.json".to_string(),
1367            purpose: "archiving".to_string(),
1368            boundary_id: Some(2),
1369        });
1370        tool.save_state(&state).unwrap();
1371
1372        // Filter by tool_name
1373        let result = tool
1374            .execute(json!({"action": "audit_access", "tool_name": "file_read"}))
1375            .await
1376            .unwrap();
1377        assert!(result.content.contains("file_read"));
1378        assert!(!result.content.contains("shell_exec"));
1379        assert!(result.content.contains("2 entries shown"));
1380
1381        // Filter by boundary_id
1382        let result = tool
1383            .execute(json!({"action": "audit_access", "boundary_id": 1}))
1384            .await
1385            .unwrap();
1386        assert!(result.content.contains("1 entries shown"));
1387        assert!(result.content.contains("career/goals.json"));
1388
1389        // Limit
1390        let result = tool
1391            .execute(json!({"action": "audit_access", "limit": 1}))
1392            .await
1393            .unwrap();
1394        assert!(result.content.contains("1 entries shown"));
1395    }
1396}