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