sqlite_graphrag/commands/
history.rs1use crate::errors::AppError;
2use crate::i18n::erros;
3use crate::output;
4use crate::paths::AppPaths;
5use crate::storage::connection::open_ro;
6use rusqlite::params;
7use rusqlite::OptionalExtension;
8use serde::Serialize;
9
10#[derive(clap::Args)]
11pub struct HistoryArgs {
12 #[arg(value_name = "NAME", conflicts_with = "name")]
14 pub name_positional: Option<String>,
15 #[arg(long)]
18 pub name: Option<String>,
19 #[arg(long, default_value = "global")]
20 pub namespace: Option<String>,
21 #[arg(long, help = "No-op; JSON is always emitted on stdout")]
22 pub json: bool,
23 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
24 pub db: Option<String>,
25}
26
27#[derive(Serialize)]
28struct HistoryVersion {
29 version: i64,
30 name: String,
31 #[serde(rename = "type")]
32 memory_type: String,
33 description: String,
34 body: String,
35 metadata: String,
36 change_reason: String,
37 changed_by: Option<String>,
38 created_at: i64,
39 created_at_iso: String,
40}
41
42#[derive(Serialize)]
43struct HistoryResponse {
44 name: String,
45 namespace: String,
46 deleted: bool,
49 versions: Vec<HistoryVersion>,
50 elapsed_ms: u64,
52}
53
54pub fn run(args: HistoryArgs) -> Result<(), AppError> {
55 let inicio = std::time::Instant::now();
56 let name = args.name_positional.or(args.name).ok_or_else(|| {
58 AppError::Validation("name required: pass as positional argument or via --name".to_string())
59 })?;
60 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
61 let paths = AppPaths::resolve(args.db.as_deref())?;
62 if !paths.db.exists() {
63 return Err(AppError::NotFound(erros::banco_nao_encontrado(
64 &paths.db.display().to_string(),
65 )));
66 }
67 let conn = open_ro(&paths.db)?;
68
69 let row: Option<(i64, Option<i64>)> = conn
73 .query_row(
74 "SELECT id, deleted_at FROM memories WHERE namespace = ?1 AND name = ?2",
75 params![namespace, name],
76 |r| Ok((r.get(0)?, r.get(1)?)),
77 )
78 .optional()?;
79 let (memory_id, deleted_at) =
80 row.ok_or_else(|| AppError::NotFound(erros::memoria_nao_encontrada(&name, &namespace)))?;
81 let deleted = deleted_at.is_some();
82
83 let mut stmt = conn.prepare(
84 "SELECT version, name, type, description, body, metadata,
85 change_reason, changed_by, created_at
86 FROM memory_versions
87 WHERE memory_id = ?1
88 ORDER BY version ASC",
89 )?;
90
91 let versions = stmt
92 .query_map(params![memory_id], |r| {
93 let created_at: i64 = r.get(8)?;
94 let created_at_iso = crate::tz::epoch_para_iso(created_at);
95 Ok(HistoryVersion {
96 version: r.get(0)?,
97 name: r.get(1)?,
98 memory_type: r.get(2)?,
99 description: r.get(3)?,
100 body: r.get(4)?,
101 metadata: r.get(5)?,
102 change_reason: r.get(6)?,
103 changed_by: r.get(7)?,
104 created_at,
105 created_at_iso,
106 })
107 })?
108 .collect::<Result<Vec<_>, _>>()?;
109
110 output::emit_json(&HistoryResponse {
111 name,
112 namespace,
113 deleted,
114 versions,
115 elapsed_ms: inicio.elapsed().as_millis() as u64,
116 })?;
117
118 Ok(())
119}
120
121#[cfg(test)]
122mod testes {
123 #[test]
124 fn epoch_zero_gera_iso_valido() {
125 let iso = crate::tz::epoch_para_iso(0);
127 assert!(iso.starts_with("1970-01-01T00:00:00"), "obtido: {iso}");
128 assert!(iso.contains("00:00"), "deve conter offset, obtido: {iso}");
129 }
130
131 #[test]
132 fn epoch_tipico_gera_iso_rfc3339() {
133 let iso = crate::tz::epoch_para_iso(1_745_000_000);
134 assert!(!iso.is_empty(), "created_at_iso não deve ser vazio");
135 assert!(iso.contains('T'), "created_at_iso deve conter separador T");
136 assert!(
138 iso.contains('+') || iso.contains('-'),
139 "deve conter sinal de offset, obtido: {iso}"
140 );
141 }
142
143 #[test]
144 fn epoch_invalido_retorna_fallback() {
145 let iso = crate::tz::epoch_para_iso(i64::MIN);
146 assert!(
147 !iso.is_empty(),
148 "epoch inválido deve retornar fallback não-vazio"
149 );
150 }
151}