Skip to main content

sqlite_graphrag/commands/
rename.rs

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