Skip to main content

sqlite_graphrag/commands/
rename.rs

1use crate::errors::AppError;
2use crate::i18n::erros;
3use crate::output;
4use crate::output::JsonOutputFormat;
5use crate::paths::AppPaths;
6use crate::storage::connection::open_rw;
7use crate::storage::{memories, versions};
8use serde::Serialize;
9
10#[derive(clap::Args)]
11pub struct RenameArgs {
12    /// Current memory name. Also accepts the alias `--old`.
13    #[arg(long, alias = "old")]
14    pub name: String,
15    /// New memory name. Also accepts the alias `--new`.
16    #[arg(long, alias = "new")]
17    pub new_name: String,
18    #[arg(long, default_value = "global")]
19    pub namespace: Option<String>,
20    /// Optimistic locking: rejeitar se updated_at atual não bater (exit 3).
21    #[arg(
22        long,
23        value_name = "EPOCH_OR_RFC3339",
24        value_parser = crate::parsers::parse_expected_updated_at,
25        long_help = "Optimistic lock: reject if updated_at does not match. \
26Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
27    )]
28    pub expected_updated_at: Option<i64>,
29    /// Optional session ID used to trace the origin of the change.
30    #[arg(long, value_name = "UUID")]
31    pub session_id: Option<String>,
32    /// Output format.
33    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
34    pub format: JsonOutputFormat,
35    #[arg(long, help = "No-op; JSON is always emitted on stdout")]
36    pub json: bool,
37    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
38    pub db: Option<String>,
39}
40
41#[derive(Serialize)]
42struct RenameResponse {
43    memory_id: i64,
44    name: String,
45    action: &'static str,
46    version: i64,
47    /// Tempo total de execução em milissegundos desde início do handler até serialização.
48    elapsed_ms: u64,
49}
50
51pub fn run(args: RenameArgs) -> Result<(), AppError> {
52    let inicio = std::time::Instant::now();
53    let _ = args.format;
54    use crate::constants::*;
55
56    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
57
58    // v1.0.20: trim_matches('-') também remove hífens trailing/leading.
59    let normalized_new_name = {
60        let lower = args.new_name.to_lowercase().replace(['_', ' '], "-");
61        let trimmed = lower.trim_matches('-').to_string();
62        if trimmed != args.new_name {
63            tracing::warn!(
64                original = %args.new_name,
65                normalized = %trimmed,
66                "new_name auto-normalized to kebab-case"
67            );
68        }
69        trimmed
70    };
71
72    if normalized_new_name.starts_with("__") {
73        return Err(AppError::Validation(
74            crate::i18n::validacao::nome_reservado(),
75        ));
76    }
77
78    if normalized_new_name.is_empty() || normalized_new_name.len() > MAX_MEMORY_NAME_LEN {
79        return Err(AppError::Validation(
80            crate::i18n::validacao::novo_nome_comprimento(MAX_MEMORY_NAME_LEN),
81        ));
82    }
83
84    {
85        let slug_re = regex::Regex::new(crate::constants::NAME_SLUG_REGEX)
86            .map_err(|e| AppError::Internal(anyhow::anyhow!("regex: {e}")))?;
87        if !slug_re.is_match(&normalized_new_name) {
88            return Err(AppError::Validation(
89                crate::i18n::validacao::novo_nome_kebab(&normalized_new_name),
90            ));
91        }
92    }
93
94    let paths = AppPaths::resolve(args.db.as_deref())?;
95    if !paths.db.exists() {
96        return Err(AppError::NotFound(erros::banco_nao_encontrado(
97            &paths.db.display().to_string(),
98        )));
99    }
100    let mut conn = open_rw(&paths.db)?;
101
102    let (memory_id, current_updated_at, _) = memories::find_by_name(&conn, &namespace, &args.name)?
103        .ok_or_else(|| AppError::NotFound(erros::memoria_nao_encontrada(&args.name, &namespace)))?;
104
105    if let Some(expected) = args.expected_updated_at {
106        if expected != current_updated_at {
107            return Err(AppError::Conflict(erros::conflito_optimistic_lock(
108                expected,
109                current_updated_at,
110            )));
111        }
112    }
113
114    let row = memories::read_by_name(&conn, &namespace, &args.name)?
115        .ok_or_else(|| AppError::Internal(anyhow::anyhow!("memory not found before rename")))?;
116
117    let memory_type = row.memory_type.clone();
118    let description = row.description.clone();
119    let body = row.body.clone();
120    let metadata = row.metadata.clone();
121
122    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
123
124    let affected = if let Some(ts) = args.expected_updated_at {
125        tx.execute(
126            "UPDATE memories SET name=?2 WHERE id=?1 AND updated_at=?3 AND deleted_at IS NULL",
127            rusqlite::params![memory_id, normalized_new_name, ts],
128        )?
129    } else {
130        tx.execute(
131            "UPDATE memories SET name=?2 WHERE id=?1 AND deleted_at IS NULL",
132            rusqlite::params![memory_id, normalized_new_name],
133        )?
134    };
135
136    if affected == 0 {
137        return Err(AppError::Conflict(
138            "optimistic lock conflict: memory was modified by another process".to_string(),
139        ));
140    }
141
142    let next_v = versions::next_version(&tx, memory_id)?;
143
144    versions::insert_version(
145        &tx,
146        memory_id,
147        next_v,
148        &normalized_new_name,
149        &memory_type,
150        &description,
151        &body,
152        &metadata,
153        None,
154        "rename",
155    )?;
156
157    tx.commit()?;
158
159    output::emit_json(&RenameResponse {
160        memory_id,
161        name: normalized_new_name,
162        action: "renamed",
163        version: next_v,
164        elapsed_ms: inicio.elapsed().as_millis() as u64,
165    })?;
166
167    Ok(())
168}
169
170#[cfg(test)]
171mod testes {
172    use crate::storage::memories::{insert, NewMemory};
173    use tempfile::TempDir;
174
175    fn setup_db() -> (TempDir, rusqlite::Connection) {
176        crate::storage::connection::register_vec_extension();
177        let dir = TempDir::new().unwrap();
178        let db_path = dir.path().join("test.db");
179        let mut conn = rusqlite::Connection::open(&db_path).unwrap();
180        crate::migrations::runner().run(&mut conn).unwrap();
181        (dir, conn)
182    }
183
184    fn nova_memoria(name: &str) -> NewMemory {
185        NewMemory {
186            namespace: "global".to_string(),
187            name: name.to_string(),
188            memory_type: "user".to_string(),
189            description: "desc".to_string(),
190            body: "corpo".to_string(),
191            body_hash: format!("hash-{name}"),
192            session_id: None,
193            source: "agent".to_string(),
194            metadata: serde_json::json!({}),
195        }
196    }
197
198    #[test]
199    fn rejeita_new_name_com_prefixo_duplo_underscore() {
200        use crate::errors::AppError;
201        let (_dir, conn) = setup_db();
202        insert(&conn, &nova_memoria("mem-teste")).unwrap();
203        drop(conn);
204
205        let err = AppError::Validation(
206            "names and namespaces starting with __ are reserved for internal use".to_string(),
207        );
208        assert!(err.to_string().contains("__"));
209        assert_eq!(err.exit_code(), 1);
210    }
211
212    #[test]
213    fn optimistic_lock_conflict_retorna_exit_3() {
214        use crate::errors::AppError;
215        let err = AppError::Conflict(
216            "optimistic lock conflict: expected updated_at=100, but current is 200".to_string(),
217        );
218        assert_eq!(err.exit_code(), 3);
219    }
220}