sqlite_graphrag/commands/
history.rs1use crate::errors::AppError;
4use crate::i18n::errors_msg;
5use crate::output;
6use crate::paths::AppPaths;
7use crate::storage::connection::open_ro;
8use rusqlite::params;
9use rusqlite::OptionalExtension;
10use serde::Serialize;
11
12#[derive(clap::Args)]
13pub struct HistoryArgs {
14 #[arg(value_name = "NAME", conflicts_with = "name")]
16 pub name_positional: Option<String>,
17 #[arg(long)]
20 pub name: Option<String>,
21 #[arg(long, default_value = "global", help = "Namespace to query")]
23 pub namespace: Option<String>,
24 #[arg(
26 long,
27 default_value_t = false,
28 help = "Omit body content from response"
29 )]
30 pub no_body: bool,
31 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
32 pub json: bool,
33 #[arg(
35 long,
36 env = "SQLITE_GRAPHRAG_DB_PATH",
37 help = "Path to graphrag.sqlite"
38 )]
39 pub db: Option<String>,
40}
41
42#[derive(Serialize)]
43struct HistoryVersion {
44 version: i64,
45 name: String,
46 #[serde(rename = "type")]
47 memory_type: String,
48 description: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 body: Option<String>,
51 metadata: serde_json::Value,
52 change_reason: String,
53 changed_by: Option<String>,
54 created_at: i64,
55 created_at_iso: String,
56}
57
58#[derive(Serialize)]
59struct HistoryResponse {
60 name: String,
61 namespace: String,
62 deleted: bool,
65 versions: Vec<HistoryVersion>,
66 elapsed_ms: u64,
68}
69
70pub fn run(args: HistoryArgs) -> Result<(), AppError> {
71 let inicio = std::time::Instant::now();
72 let name = args.name_positional.or(args.name).ok_or_else(|| {
74 AppError::Validation("name required: pass as positional argument or via --name".to_string())
75 })?;
76 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
77 let paths = AppPaths::resolve(args.db.as_deref())?;
78 if !paths.db.exists() {
79 return Err(AppError::NotFound(errors_msg::database_not_found(
80 &paths.db.display().to_string(),
81 )));
82 }
83 let conn = open_ro(&paths.db)?;
84
85 let row: Option<(i64, Option<i64>)> = conn
89 .query_row(
90 "SELECT id, deleted_at FROM memories WHERE namespace = ?1 AND name = ?2",
91 params![namespace, name],
92 |r| Ok((r.get(0)?, r.get(1)?)),
93 )
94 .optional()?;
95 let (memory_id, deleted_at) =
96 row.ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
97 let deleted = deleted_at.is_some();
98
99 let mut stmt = conn.prepare(
100 "SELECT version, name, type, description, body, metadata,
101 change_reason, changed_by, created_at
102 FROM memory_versions
103 WHERE memory_id = ?1
104 ORDER BY version ASC",
105 )?;
106
107 let no_body = args.no_body;
108 let versions = stmt
109 .query_map(params![memory_id], |r| {
110 let created_at: i64 = r.get(8)?;
111 let created_at_iso = crate::tz::epoch_to_iso(created_at);
112 let body_str: String = r.get(4)?;
113 let metadata_str: String = r.get(5)?;
114 let metadata_value: serde_json::Value = serde_json::from_str(&metadata_str)
115 .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
116 Ok(HistoryVersion {
117 version: r.get(0)?,
118 name: r.get(1)?,
119 memory_type: r.get(2)?,
120 description: r.get(3)?,
121 body: if no_body { None } else { Some(body_str) },
122 metadata: metadata_value,
123 change_reason: r.get(6)?,
124 changed_by: r.get(7)?,
125 created_at,
126 created_at_iso,
127 })
128 })?
129 .collect::<Result<Vec<_>, _>>()?;
130
131 output::emit_json(&HistoryResponse {
132 name,
133 namespace,
134 deleted,
135 versions,
136 elapsed_ms: inicio.elapsed().as_millis() as u64,
137 })?;
138
139 Ok(())
140}
141
142#[cfg(test)]
143mod tests {
144 #[test]
145 fn epoch_zero_gera_iso_valido() {
146 let iso = crate::tz::epoch_to_iso(0);
148 assert!(iso.starts_with("1970-01-01T00:00:00"), "obtido: {iso}");
149 assert!(iso.contains("00:00"), "deve conter offset, obtido: {iso}");
150 }
151
152 #[test]
153 fn epoch_tipico_gera_iso_rfc3339() {
154 let iso = crate::tz::epoch_to_iso(1_745_000_000);
155 assert!(!iso.is_empty(), "created_at_iso não deve ser vazio");
156 assert!(iso.contains('T'), "created_at_iso deve conter separador T");
157 assert!(
159 iso.contains('+') || iso.contains('-'),
160 "deve conter sinal de offset, obtido: {iso}"
161 );
162 }
163
164 #[test]
165 fn epoch_invalido_retorna_fallback() {
166 let iso = crate::tz::epoch_to_iso(i64::MIN);
167 assert!(
168 !iso.is_empty(),
169 "epoch inválido deve retornar fallback não-vazio"
170 );
171 }
172}