Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ build/
.tree-ring/
target/
outputs/
.worktrees/
bindings/python/python/tree_ring_memory/*.abi3.so
bindings/python/python/tree_ring_memory/*.so
bindings/python/python/tree_ring_memory/*.pyd
104 changes: 104 additions & 0 deletions crates/tree-ring-memory-cli/src/actions/adapters.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use std::path::PathBuf;

use tree_ring_memory_core::{
collect_dox_memories, collect_revolve_memories, DoxSyncReport, DoxSyncRequest,
RevolveSyncReport, RevolveSyncRequest,
};
use tree_ring_memory_sqlite::SQLiteMemoryStore;

use super::ActionResult;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DoxSyncActionRequest {
pub source_root: PathBuf,
pub project: Option<String>,
pub dry_run: bool,
}

#[derive(Debug, Clone, PartialEq)]
pub struct DoxSyncActionReport {
pub report: DoxSyncReport,
pub dry_run: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RevolveSyncActionRequest {
pub source_root: PathBuf,
pub project: Option<String>,
pub dry_run: bool,
}

#[derive(Debug, Clone, PartialEq)]
pub struct RevolveSyncActionReport {
pub report: RevolveSyncReport,
pub dry_run: bool,
}

pub fn sync_dox(
store: Option<&mut SQLiteMemoryStore>,
request: DoxSyncActionRequest,
) -> ActionResult<DoxSyncActionReport> {
let mut dox_request = DoxSyncRequest::new(request.source_root);
dox_request.project = request.project;
let report = collect_dox_memories(&dox_request).map_err(|err| err.to_string())?;
if !request.dry_run {
let store = store.ok_or_else(|| {
"DOX sync action requires an open writable store when dry_run=false".to_string()
})?;
store
.put_many(&report.events)
.map_err(|err| err.to_string())?;
}
Ok(DoxSyncActionReport {
report,
dry_run: request.dry_run,
})
}

pub fn sync_revolve(
store: Option<&mut SQLiteMemoryStore>,
request: RevolveSyncActionRequest,
) -> ActionResult<RevolveSyncActionReport> {
let mut revolve_request = RevolveSyncRequest::new(request.source_root);
revolve_request.project = request.project;
let report = collect_revolve_memories(&revolve_request).map_err(|err| err.to_string())?;
if !request.dry_run {
let store = store.ok_or_else(|| {
"Revolve sync action requires an open writable store when dry_run=false".to_string()
})?;
store
.put_many(&report.events)
.map_err(|err| err.to_string())?;
}
Ok(RevolveSyncActionReport {
report,
dry_run: request.dry_run,
})
}

#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;

use super::*;

#[test]
fn dox_action_dry_run_does_not_write_events() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("AGENTS.md"), "# Rules\n\nAlways run tests.").unwrap();

let report = sync_dox(
None,
DoxSyncActionRequest {
source_root: dir.path().to_path_buf(),
project: Some("tree-ring".to_string()),
dry_run: true,
},
)
.unwrap();

assert_eq!(report.report.memory_count, 1);
assert!(!dir.path().join("memory.sqlite").exists());
}
}
51 changes: 51 additions & 0 deletions crates/tree-ring-memory-cli/src/actions/audit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use std::path::Path;

use tree_ring_memory_core::{audit_memories, AuditReport};
use tree_ring_memory_sqlite::SQLiteMemoryStore;

use super::ActionResult;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuditActionRequest {
pub audit_type: String,
}

#[derive(Debug, Clone, PartialEq)]
pub struct AuditActionReport {
pub report: AuditReport,
}

pub fn audit_store(db_path: &Path, request: AuditActionRequest) -> ActionResult<AuditActionReport> {
let report = if db_path.exists() {
SQLiteMemoryStore::open_read_only(db_path)
.and_then(|store| store.audit(&request.audit_type))
.map_err(|err| err.to_string())?
} else {
audit_memories(&[], &request.audit_type).map_err(|err| err.to_string())?
};
Ok(AuditActionReport { report })
}

#[cfg(test)]
mod tests {
use tempfile::tempdir;

use super::*;

#[test]
fn audit_action_missing_store_reports_empty_audit_without_creating_store() {
let dir = tempdir().unwrap();
let db_path = dir.path().join(".tree-ring/memory.sqlite");

let report = audit_store(
&db_path,
AuditActionRequest {
audit_type: "all".to_string(),
},
)
.unwrap();

assert_eq!(report.report.memory_count, 0);
assert!(!db_path.exists());
}
}
160 changes: 160 additions & 0 deletions crates/tree-ring-memory-cli/src/actions/export_import.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
use std::fs;
use std::path::PathBuf;

use serde_json::json;
use tree_ring_memory_core::{decode_jsonl, normalize_import_events};
use tree_ring_memory_sqlite::{ExportReport, ImportReport, SQLiteMemoryStore};

use super::ActionResult;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportActionRequest {
pub output: Option<PathBuf>,
pub include_sensitive: bool,
pub include_superseded: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportActionReport {
pub jsonl: Option<String>,
pub output: Option<PathBuf>,
pub report: ExportReport,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportActionRequest {
pub path: PathBuf,
pub dry_run: bool,
pub replace_existing: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportActionReport {
pub path: PathBuf,
pub report: ImportReport,
}

pub fn export_jsonl(
store: &SQLiteMemoryStore,
request: ExportActionRequest,
) -> ActionResult<ExportActionReport> {
let (jsonl, report) = store
.export_jsonl(request.include_sensitive, request.include_superseded)
.map_err(|err| err.to_string())?;
if let Some(output) = request.output {
if let Some(parent) = output.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).map_err(|err| err.to_string())?;
}
}
fs::write(&output, jsonl).map_err(|err| err.to_string())?;
Ok(ExportActionReport {
jsonl: None,
output: Some(output),
report,
})
} else {
Ok(ExportActionReport {
jsonl: Some(jsonl),
output: None,
report,
})
}
}

pub fn import_jsonl(
store: Option<&mut SQLiteMemoryStore>,
request: ImportActionRequest,
) -> ActionResult<ImportActionReport> {
let input = fs::read_to_string(&request.path).map_err(|err| err.to_string())?;
let report = if request.dry_run {
let decoded = decode_jsonl(&input).map_err(|err| err.to_string())?;
let events = normalize_import_events(decoded.events).map_err(|err| err.to_string())?;
ImportReport {
valid_count: events.len(),
inserted_count: 0,
replaced_count: 0,
skipped_duplicate_count: 0,
dry_run: true,
}
} else {
let store = store.ok_or_else(|| {
"import action requires an open writable store when dry_run=false".to_string()
})?;
store
.import_jsonl(&input, false, request.replace_existing)
.map_err(|err| err.to_string())?
};
Ok(ImportActionReport {
path: request.path,
report,
})
}

pub fn import_json_payload(report: &ImportActionReport) -> serde_json::Value {
json!({
"ok": true,
"path": report.path,
"valid_count": report.report.valid_count,
"inserted_count": report.report.inserted_count,
"replaced_count": report.report.replaced_count,
"skipped_duplicate_count": report.report.skipped_duplicate_count,
"dry_run": report.report.dry_run,
})
}

#[cfg(test)]
mod tests {
use tempfile::tempdir;
use tree_ring_memory_core::MemoryEvent;

use super::*;

#[test]
fn export_action_can_return_stdout_jsonl() {
let dir = tempdir().unwrap();
let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap();
store
.put(&MemoryEvent::new("Export through shared action.", "lesson").unwrap())
.unwrap();

let report = export_jsonl(
&store,
ExportActionRequest {
output: None,
include_sensitive: false,
include_superseded: false,
},
)
.unwrap();

assert_eq!(report.report.memory_count, 1);
assert!(report.jsonl.unwrap().contains("memory_event"));
}

#[test]
fn import_action_dry_run_validates_without_writing() {
let dir = tempdir().unwrap();
let mut source = SQLiteMemoryStore::open(dir.path().join("source.sqlite")).unwrap();
let target = SQLiteMemoryStore::open(dir.path().join("target.sqlite")).unwrap();
source
.put(&MemoryEvent::new("Import through shared action.", "lesson").unwrap())
.unwrap();
let (jsonl, _) = source.export_jsonl(false, false).unwrap();
let input = dir.path().join("input.jsonl");
fs::write(&input, jsonl).unwrap();

let report = import_jsonl(
None,
ImportActionRequest {
path: input,
dry_run: true,
replace_existing: false,
},
)
.unwrap();

assert_eq!(report.report.valid_count, 1);
assert_eq!(target.list_all(true).unwrap().len(), 0);
}
Comment on lines +135 to +159

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Test doesn't actually exercise the "no write" guarantee it claims to verify.

target is created but never passed into import_jsonl(None, ...) — the call always uses None for the store. So assert_eq!(target.list_all(true).unwrap().len(), 0) is trivially true regardless of dry-run behavior; target is untouched by the code under test. This gives false confidence that dry-run correctly avoids writes when a store is provided.

🧪 Suggested fix: exercise the store-provided dry-run path
-        let report = import_jsonl(
-            None,
-            ImportActionRequest {
-                path: input,
-                dry_run: true,
-                replace_existing: false,
-            },
-        )
-        .unwrap();
-
-        assert_eq!(report.report.valid_count, 1);
-        assert_eq!(target.list_all(true).unwrap().len(), 0);
+        let mut target = target;
+        let report = import_jsonl(
+            Some(&mut target),
+            ImportActionRequest {
+                path: input,
+                dry_run: true,
+                replace_existing: false,
+            },
+        )
+        .unwrap();
+
+        assert_eq!(report.report.valid_count, 1);
+        assert_eq!(target.list_all(true).unwrap().len(), 0);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[test]
fn import_action_dry_run_validates_without_writing() {
let dir = tempdir().unwrap();
let mut source = SQLiteMemoryStore::open(dir.path().join("source.sqlite")).unwrap();
let target = SQLiteMemoryStore::open(dir.path().join("target.sqlite")).unwrap();
source
.put(&MemoryEvent::new("Import through shared action.", "lesson").unwrap())
.unwrap();
let (jsonl, _) = source.export_jsonl(false, false).unwrap();
let input = dir.path().join("input.jsonl");
fs::write(&input, jsonl).unwrap();
let report = import_jsonl(
None,
ImportActionRequest {
path: input,
dry_run: true,
replace_existing: false,
},
)
.unwrap();
assert_eq!(report.report.valid_count, 1);
assert_eq!(target.list_all(true).unwrap().len(), 0);
}
#[test]
fn import_action_dry_run_validates_without_writing() {
let dir = tempdir().unwrap();
let mut source = SQLiteMemoryStore::open(dir.path().join("source.sqlite")).unwrap();
let target = SQLiteMemoryStore::open(dir.path().join("target.sqlite")).unwrap();
source
.put(&MemoryEvent::new("Import through shared action.", "lesson").unwrap())
.unwrap();
let (jsonl, _) = source.export_jsonl(false, false).unwrap();
let input = dir.path().join("input.jsonl");
fs::write(&input, jsonl).unwrap();
let mut target = target;
let report = import_jsonl(
Some(&mut target),
ImportActionRequest {
path: input,
dry_run: true,
replace_existing: false,
},
)
.unwrap();
assert_eq!(report.report.valid_count, 1);
assert_eq!(target.list_all(true).unwrap().len(), 0);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/tree-ring-memory-cli/src/actions/export_import.rs` around lines 135 -
159, The dry-run test in import_action_dry_run_validates_without_writing does
not exercise the actual write path because import_jsonl is called with None, so
target is never used. Update the test to pass the created SQLiteMemoryStore into
import_jsonl through the shared import action path, then verify that the store
remains empty after a dry run. Keep the assertions tied to import_jsonl and
ImportActionRequest so the test validates the real no-write behavior rather than
an untouched target.

}
39 changes: 39 additions & 0 deletions crates/tree-ring-memory-cli/src/actions/integrations.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use std::path::PathBuf;

use crate::integrations::{scan_integrations, IntegrationScanReport};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IntegrationScanRequest {
pub source_root: PathBuf,
}

#[derive(Debug, Clone, PartialEq)]
pub struct IntegrationScanActionReport {
pub report: IntegrationScanReport,
}

pub fn scan(request: IntegrationScanRequest) -> IntegrationScanActionReport {
IntegrationScanActionReport {
report: scan_integrations(&request.source_root),
}
}

#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;

use super::*;

#[test]
fn integration_action_scans_project_markers() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("AGENTS.md"), "# Rules").unwrap();

let report = scan(IntegrationScanRequest {
source_root: dir.path().to_path_buf(),
});

assert!(report.report.detected_count > 0);
}
}
Loading
Loading