Skip to main content

sqlite_graphrag/commands/
rename.rs

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