Skip to main content

alopex_cli/commands/
kv.rs

1//! KV Command - Key-Value operations
2//!
3//! Supports: get, put, delete, list
4
5use std::io::Write;
6use std::path::PathBuf;
7use std::time::Duration;
8
9use alopex_embedded::{Database, TransactionManager as Transaction, TxnMode};
10use serde::{Deserialize, Serialize};
11
12use crate::batch::BatchMode;
13use crate::cli::{KvCommand, KvTxnCommand, OutputFormat};
14use crate::client::http::{ClientError, HttpClient};
15use crate::error::{CliError, Result};
16use crate::models::{Column, DataType, Row, Value};
17use crate::output::formatter::Formatter;
18use crate::output::RowCollector;
19use crate::streaming::{StreamingWriter, WriteStatus};
20use crate::tui::admin::{AdminBackend, AdminContext, AdminTarget, AuthCapabilities};
21use crate::tui::renderer::render_output;
22
23const DEFAULT_TXN_TIMEOUT_SECS: u64 = 60;
24
25#[derive(Debug, Serialize)]
26struct RemoteKvGetRequest {
27    key: String,
28}
29
30#[derive(Debug, Serialize)]
31struct RemoteKvPutRequest {
32    key: String,
33    value: Vec<u8>,
34}
35
36#[derive(Debug, Serialize)]
37struct RemoteKvDeleteRequest {
38    key: String,
39}
40
41#[derive(Debug, Serialize)]
42struct RemoteKvListRequest {
43    prefix: Option<String>,
44}
45
46#[derive(Debug, Serialize)]
47struct RemoteKvTxnBeginRequest {
48    timeout_secs: Option<u64>,
49}
50
51#[derive(Debug, Serialize)]
52struct RemoteKvTxnGetRequest {
53    txn_id: String,
54    key: String,
55}
56
57#[derive(Debug, Serialize)]
58struct RemoteKvTxnPutRequest {
59    txn_id: String,
60    key: String,
61    value: Vec<u8>,
62}
63
64#[derive(Debug, Serialize)]
65struct RemoteKvTxnDeleteRequest {
66    txn_id: String,
67    key: String,
68}
69
70#[derive(Debug, Serialize)]
71struct RemoteKvTxnCommitRequest {
72    txn_id: String,
73}
74
75#[derive(Debug, Serialize)]
76struct RemoteKvTxnRollbackRequest {
77    txn_id: String,
78}
79
80#[derive(Debug, Deserialize)]
81struct RemoteKvGetResponse {
82    value: Option<Vec<u8>>,
83}
84
85#[derive(Debug, Deserialize)]
86struct RemoteKvListEntry {
87    key: Vec<u8>,
88    value: Vec<u8>,
89}
90
91#[derive(Debug, Deserialize)]
92struct RemoteKvListResponse {
93    entries: Vec<RemoteKvListEntry>,
94}
95
96#[derive(Debug, Deserialize)]
97struct RemoteKvStatusResponse {
98    success: bool,
99}
100
101#[derive(Debug, Deserialize)]
102struct RemoteKvTxnBeginResponse {
103    txn_id: String,
104}
105
106/// Execute a KV command.
107///
108/// # Arguments
109///
110/// * `db` - The database instance.
111/// * `cmd` - The KV subcommand to execute.
112/// * `writer` - The streaming writer for output.
113pub fn execute<W: Write>(
114    db: &Database,
115    cmd: KvCommand,
116    writer: &mut StreamingWriter<W>,
117) -> Result<()> {
118    match cmd {
119        KvCommand::Get { key } => execute_get(db, &key, writer),
120        KvCommand::Put { key, value } => execute_put(db, &key, &value, writer),
121        KvCommand::Delete { key } => execute_delete(db, &key, writer),
122        KvCommand::List { prefix } => execute_list(db, prefix.as_deref(), writer),
123        KvCommand::Txn(cmd) => execute_txn_command(db, cmd, writer),
124    }
125}
126
127#[allow(clippy::too_many_arguments)]
128pub fn execute_tui(
129    db: &Database,
130    cmd: KvCommand,
131    batch_mode: &BatchMode,
132    output_format: OutputFormat,
133    columns: Vec<Column>,
134    limit: Option<usize>,
135    quiet: bool,
136    connection_label: impl Into<String>,
137    data_dir: Option<PathBuf>,
138) -> Result<()> {
139    let connection_label = connection_label.into();
140    let context_message = Some(kv_command_context(&cmd));
141    let admin_label = connection_label.clone();
142    let admin_data_dir = data_dir.clone();
143    let admin_launcher: Option<Box<dyn FnMut() -> Result<()> + '_>> = Some(Box::new(move || {
144        let connection_label = admin_label.clone();
145        let data_dir = admin_data_dir.clone();
146        crate::tui::admin::run_admin_ui(AdminContext {
147            connection_label,
148            auth: AuthCapabilities::full(),
149            backend: AdminBackend::Local {
150                db,
151                batch_mode,
152                output_format,
153                limit,
154                quiet,
155                data_dir,
156            },
157            initial_target: Some(AdminTarget::Kv),
158        })
159    }));
160    let collector = RowCollector::new();
161    let formatter = Box::new(collector.formatter());
162    let mut sink = std::io::sink();
163    let mut writer =
164        StreamingWriter::new(&mut sink, formatter, columns.clone(), limit).with_quiet(quiet);
165    execute(db, cmd, &mut writer)?;
166    let warning = collector.truncation_warning();
167    render_output(
168        columns,
169        collector.rows(),
170        connection_label,
171        context_message,
172        true,
173        warning,
174        output_format,
175        admin_launcher,
176    )
177}
178
179/// Execute a KV command against a remote server.
180pub async fn execute_remote_with_formatter<W: Write>(
181    client: &HttpClient,
182    cmd: &KvCommand,
183    writer: &mut W,
184    formatter: Box<dyn Formatter>,
185    limit: Option<usize>,
186    quiet: bool,
187) -> Result<()> {
188    match cmd {
189        KvCommand::Get { key } => {
190            let request = RemoteKvGetRequest { key: key.clone() };
191            let response: RemoteKvGetResponse = client
192                .post_json("kv/get", &request)
193                .await
194                .map_err(map_client_error)?;
195            let Some(value) = response.value else {
196                return Err(CliError::InvalidArgument(format!("Key not found: {}", key)));
197            };
198
199            let columns = kv_columns();
200            let mut streaming_writer =
201                StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
202            streaming_writer.prepare(Some(1))?;
203            let row = Row::new(vec![Value::Text(key.clone()), bytes_to_value(value)]);
204            streaming_writer.write_row(row)?;
205            streaming_writer.finish()
206        }
207        KvCommand::Put { key, value } => {
208            let request = RemoteKvPutRequest {
209                key: key.clone(),
210                value: value.as_bytes().to_vec(),
211            };
212            let response: RemoteKvStatusResponse = client
213                .post_json("kv/put", &request)
214                .await
215                .map_err(map_client_error)?;
216            if response.success {
217                if quiet {
218                    return Ok(());
219                }
220                let columns = kv_status_columns();
221                let mut streaming_writer =
222                    StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
223                streaming_writer.prepare(Some(1))?;
224                let row = Row::new(vec![
225                    Value::Text("OK".to_string()),
226                    Value::Text(format!("Set key: {}", key)),
227                ]);
228                streaming_writer.write_row(row)?;
229                streaming_writer.finish()
230            } else {
231                Err(CliError::InvalidArgument("Failed to set key".to_string()))
232            }
233        }
234        KvCommand::Delete { key } => {
235            let request = RemoteKvDeleteRequest { key: key.clone() };
236            let response: RemoteKvStatusResponse = client
237                .post_json("kv/delete", &request)
238                .await
239                .map_err(map_client_error)?;
240            if response.success {
241                if quiet {
242                    return Ok(());
243                }
244                let columns = kv_status_columns();
245                let mut streaming_writer =
246                    StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
247                streaming_writer.prepare(Some(1))?;
248                let row = Row::new(vec![
249                    Value::Text("OK".to_string()),
250                    Value::Text(format!("Deleted key: {}", key)),
251                ]);
252                streaming_writer.write_row(row)?;
253                streaming_writer.finish()
254            } else {
255                Err(CliError::InvalidArgument(
256                    "Failed to delete key".to_string(),
257                ))
258            }
259        }
260        KvCommand::List { prefix } => {
261            let request = RemoteKvListRequest {
262                prefix: prefix.clone(),
263            };
264            let response: RemoteKvListResponse = client
265                .post_json("kv/list", &request)
266                .await
267                .map_err(map_client_error)?;
268            let columns = kv_columns();
269            let mut streaming_writer =
270                StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
271            streaming_writer.prepare(Some(response.entries.len()))?;
272            for entry in response.entries {
273                let row = Row::new(vec![bytes_to_value(entry.key), bytes_to_value(entry.value)]);
274                match streaming_writer.write_row(row)? {
275                    WriteStatus::LimitReached => break,
276                    WriteStatus::Continue => {}
277                }
278            }
279            streaming_writer.finish()
280        }
281        KvCommand::Txn(txn_cmd) => {
282            execute_remote_txn_command(client, txn_cmd, writer, formatter, limit, quiet).await
283        }
284    }
285}
286
287#[allow(clippy::too_many_arguments)]
288pub async fn execute_remote_tui<'a>(
289    client: &HttpClient,
290    cmd: &KvCommand,
291    columns: Vec<Column>,
292    output_format: OutputFormat,
293    limit: Option<usize>,
294    quiet: bool,
295    connection_label: impl Into<String>,
296    admin_launcher: Option<Box<dyn FnMut() -> Result<()> + 'a>>,
297) -> Result<()> {
298    let collector = RowCollector::new();
299    let formatter = Box::new(collector.formatter());
300    let mut sink = std::io::sink();
301    execute_remote_with_formatter(client, cmd, &mut sink, formatter, limit, quiet).await?;
302    let warning = collector.truncation_warning();
303    render_output(
304        columns,
305        collector.rows(),
306        connection_label,
307        Some(kv_command_context(cmd)),
308        true,
309        warning,
310        output_format,
311        admin_launcher,
312    )
313}
314
315async fn execute_remote_txn_command<W: Write>(
316    client: &HttpClient,
317    cmd: &KvTxnCommand,
318    writer: &mut W,
319    formatter: Box<dyn Formatter>,
320    limit: Option<usize>,
321    quiet: bool,
322) -> Result<()> {
323    match cmd {
324        KvTxnCommand::Begin { timeout_secs } => {
325            let request = RemoteKvTxnBeginRequest {
326                timeout_secs: *timeout_secs,
327            };
328            let response: RemoteKvTxnBeginResponse = client
329                .post_json("kv/txn/begin", &request)
330                .await
331                .map_err(map_client_error)?;
332            let columns = kv_columns();
333            let mut streaming_writer =
334                StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
335            streaming_writer.prepare(Some(1))?;
336            let row = Row::new(vec![
337                Value::Text("txn_id".to_string()),
338                Value::Text(response.txn_id),
339            ]);
340            streaming_writer.write_row(row)?;
341            streaming_writer.finish()
342        }
343        KvTxnCommand::Get { key, txn_id } => {
344            let request = RemoteKvTxnGetRequest {
345                txn_id: txn_id.clone(),
346                key: key.clone(),
347            };
348            let response: RemoteKvGetResponse = client
349                .post_json("kv/txn/get", &request)
350                .await
351                .map_err(map_client_error)?;
352            let Some(value) = response.value else {
353                return Err(CliError::InvalidArgument(format!("Key not found: {}", key)));
354            };
355            let columns = kv_columns();
356            let mut streaming_writer =
357                StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
358            streaming_writer.prepare(Some(1))?;
359            let row = Row::new(vec![Value::Text(key.clone()), bytes_to_value(value)]);
360            streaming_writer.write_row(row)?;
361            streaming_writer.finish()
362        }
363        KvTxnCommand::Put { key, value, txn_id } => {
364            let request = RemoteKvTxnPutRequest {
365                txn_id: txn_id.clone(),
366                key: key.clone(),
367                value: value.as_bytes().to_vec(),
368            };
369            let response: RemoteKvStatusResponse = client
370                .post_json("kv/txn/put", &request)
371                .await
372                .map_err(map_client_error)?;
373            if response.success {
374                let columns = kv_status_columns();
375                let mut streaming_writer =
376                    StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
377                write_status_if_needed(&mut streaming_writer, &format!("Staged key: {}", key))
378            } else {
379                Err(CliError::InvalidArgument("Failed to stage key".to_string()))
380            }
381        }
382        KvTxnCommand::Delete { key, txn_id } => {
383            let request = RemoteKvTxnDeleteRequest {
384                txn_id: txn_id.clone(),
385                key: key.clone(),
386            };
387            let response: RemoteKvStatusResponse = client
388                .post_json("kv/txn/delete", &request)
389                .await
390                .map_err(map_client_error)?;
391            if response.success {
392                let columns = kv_status_columns();
393                let mut streaming_writer =
394                    StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
395                write_status_if_needed(&mut streaming_writer, &format!("Staged delete: {}", key))
396            } else {
397                Err(CliError::InvalidArgument(
398                    "Failed to stage delete".to_string(),
399                ))
400            }
401        }
402        KvTxnCommand::Commit { txn_id } => {
403            let request = RemoteKvTxnCommitRequest {
404                txn_id: txn_id.clone(),
405            };
406            let response: RemoteKvStatusResponse = client
407                .post_json("kv/txn/commit", &request)
408                .await
409                .map_err(map_client_error)?;
410            if response.success {
411                let columns = kv_status_columns();
412                let mut streaming_writer =
413                    StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
414                write_status_if_needed(
415                    &mut streaming_writer,
416                    &format!("Committed transaction: {}", txn_id),
417                )
418            } else {
419                Err(CliError::InvalidArgument(
420                    "Failed to commit transaction".to_string(),
421                ))
422            }
423        }
424        KvTxnCommand::Rollback { txn_id } => {
425            let request = RemoteKvTxnRollbackRequest {
426                txn_id: txn_id.clone(),
427            };
428            let response: RemoteKvStatusResponse = client
429                .post_json("kv/txn/rollback", &request)
430                .await
431                .map_err(map_client_error)?;
432            if response.success {
433                let columns = kv_status_columns();
434                let mut streaming_writer =
435                    StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
436                write_status_if_needed(
437                    &mut streaming_writer,
438                    &format!("Rolled back transaction: {}", txn_id),
439                )
440            } else {
441                Err(CliError::InvalidArgument(
442                    "Failed to rollback transaction".to_string(),
443                ))
444            }
445        }
446    }
447}
448
449fn map_client_error(err: ClientError) -> CliError {
450    match err {
451        ClientError::Request { source, .. } => {
452            CliError::ServerConnection(format!("request failed: {source}"))
453        }
454        ClientError::InvalidUrl(message) => CliError::InvalidArgument(message),
455        ClientError::Build(message) => CliError::InvalidArgument(message),
456        ClientError::Auth(err) => CliError::InvalidArgument(err.to_string()),
457        ClientError::HttpStatus { status, body } => {
458            CliError::InvalidArgument(format!("Server error: HTTP {} - {}", status.as_u16(), body))
459        }
460    }
461}
462
463fn kv_command_context(cmd: &KvCommand) -> String {
464    match cmd {
465        KvCommand::Get { key } => format!("kv get {key}"),
466        KvCommand::Put { key, .. } => format!("kv put {key}"),
467        KvCommand::Delete { key } => format!("kv delete {key}"),
468        KvCommand::List { prefix } => match prefix {
469            Some(prefix) => format!("kv list --prefix {prefix}"),
470            None => "kv list".to_string(),
471        },
472        KvCommand::Txn(command) => match command {
473            KvTxnCommand::Begin { timeout_secs } => match timeout_secs {
474                Some(secs) => format!("kv txn begin --timeout-secs {secs}"),
475                None => "kv txn begin".to_string(),
476            },
477            KvTxnCommand::Get { key, txn_id } => {
478                format!("kv txn get {key} --txn-id {txn_id}")
479            }
480            KvTxnCommand::Put { key, txn_id, .. } => {
481                format!("kv txn put {key} --txn-id {txn_id}")
482            }
483            KvTxnCommand::Delete { key, txn_id } => {
484                format!("kv txn delete {key} --txn-id {txn_id}")
485            }
486            KvTxnCommand::Commit { txn_id } => format!("kv txn commit --txn-id {txn_id}"),
487            KvTxnCommand::Rollback { txn_id } => format!("kv txn rollback --txn-id {txn_id}"),
488        },
489    }
490}
491
492fn bytes_to_value(bytes: Vec<u8>) -> Value {
493    match std::str::from_utf8(&bytes) {
494        Ok(s) => Value::Text(s.to_string()),
495        Err(_) => Value::Bytes(bytes),
496    }
497}
498
499fn map_txn_error(txn_id: &str, err: alopex_embedded::Error) -> CliError {
500    match err {
501        alopex_embedded::Error::InvalidTransactionId(_) => {
502            CliError::InvalidTransactionId(txn_id.to_string())
503        }
504        other => CliError::Database(other),
505    }
506}
507
508fn map_txn_result<T>(
509    txn_id: &str,
510    result: std::result::Result<T, alopex_embedded::Error>,
511) -> Result<T> {
512    result.map_err(|err| map_txn_error(txn_id, err))
513}
514
515fn ensure_txn_not_expired(db: &Database, txn_id: &str) -> Result<()> {
516    let expired = map_txn_result(txn_id, Transaction::is_expired(db, txn_id))?;
517    if expired {
518        let _ = Transaction::rollback(db, txn_id);
519        return Err(CliError::TransactionTimeout(txn_id.to_string()));
520    }
521    Ok(())
522}
523
524fn execute_txn_command<W: Write>(
525    db: &Database,
526    cmd: KvTxnCommand,
527    writer: &mut StreamingWriter<W>,
528) -> Result<()> {
529    match cmd {
530        KvTxnCommand::Begin { timeout_secs } => execute_txn_begin(db, timeout_secs, writer),
531        KvTxnCommand::Get { key, txn_id } => execute_txn_get(db, &key, &txn_id, writer),
532        KvTxnCommand::Put { key, value, txn_id } => {
533            execute_txn_put(db, &key, &value, &txn_id, writer)
534        }
535        KvTxnCommand::Delete { key, txn_id } => execute_txn_delete(db, &key, &txn_id, writer),
536        KvTxnCommand::Commit { txn_id } => execute_txn_commit(db, &txn_id, writer),
537        KvTxnCommand::Rollback { txn_id } => execute_txn_rollback(db, &txn_id, writer),
538    }
539}
540
541fn execute_txn_begin<W: Write>(
542    db: &Database,
543    timeout_secs: Option<u64>,
544    writer: &mut StreamingWriter<W>,
545) -> Result<()> {
546    let timeout = Duration::from_secs(timeout_secs.unwrap_or(DEFAULT_TXN_TIMEOUT_SECS));
547    let txn_id = Transaction::begin_with_timeout(db, timeout)?;
548
549    writer.prepare(Some(1))?;
550    let row = Row::new(vec![Value::Text("txn_id".to_string()), Value::Text(txn_id)]);
551    writer.write_row(row)?;
552    writer.finish()?;
553    Ok(())
554}
555
556fn execute_txn_get<W: Write>(
557    db: &Database,
558    key: &str,
559    txn_id: &str,
560    writer: &mut StreamingWriter<W>,
561) -> Result<()> {
562    ensure_txn_not_expired(db, txn_id)?;
563    let value = map_txn_result(txn_id, Transaction::get(db, txn_id, key.as_bytes()))?;
564    write_kv_value(key, value, writer)
565}
566
567fn execute_txn_put<W: Write>(
568    db: &Database,
569    key: &str,
570    value: &str,
571    txn_id: &str,
572    writer: &mut StreamingWriter<W>,
573) -> Result<()> {
574    ensure_txn_not_expired(db, txn_id)?;
575    map_txn_result(
576        txn_id,
577        Transaction::put(db, txn_id, key.as_bytes(), value.as_bytes()),
578    )?;
579
580    write_status_if_needed(writer, &format!("Staged key: {}", key))
581}
582
583fn execute_txn_delete<W: Write>(
584    db: &Database,
585    key: &str,
586    txn_id: &str,
587    writer: &mut StreamingWriter<W>,
588) -> Result<()> {
589    ensure_txn_not_expired(db, txn_id)?;
590    map_txn_result(txn_id, Transaction::delete(db, txn_id, key.as_bytes()))?;
591
592    write_status_if_needed(writer, &format!("Staged delete: {}", key))
593}
594
595fn execute_txn_commit<W: Write>(
596    db: &Database,
597    txn_id: &str,
598    writer: &mut StreamingWriter<W>,
599) -> Result<()> {
600    ensure_txn_not_expired(db, txn_id)?;
601    map_txn_result(txn_id, Transaction::commit(db, txn_id))?;
602
603    write_status_if_needed(writer, &format!("Committed transaction: {}", txn_id))
604}
605
606fn execute_txn_rollback<W: Write>(
607    db: &Database,
608    txn_id: &str,
609    writer: &mut StreamingWriter<W>,
610) -> Result<()> {
611    map_txn_result(txn_id, Transaction::rollback(db, txn_id))?;
612
613    write_status_if_needed(writer, &format!("Rolled back transaction: {}", txn_id))
614}
615
616fn write_kv_value<W: Write>(
617    key: &str,
618    value: Option<Vec<u8>>,
619    writer: &mut StreamingWriter<W>,
620) -> Result<()> {
621    let Some(value) = value else {
622        return Err(CliError::InvalidArgument(format!("Key not found: {}", key)));
623    };
624
625    let value_display = match std::str::from_utf8(&value) {
626        Ok(s) => Value::Text(s.to_string()),
627        Err(_) => Value::Bytes(value),
628    };
629    writer.prepare(Some(1))?;
630    let row = Row::new(vec![Value::Text(key.to_string()), value_display]);
631    writer.write_row(row)?;
632    writer.finish()?;
633    Ok(())
634}
635
636fn write_status_if_needed<W: Write>(writer: &mut StreamingWriter<W>, message: &str) -> Result<()> {
637    if writer.is_quiet() {
638        return Ok(());
639    }
640
641    writer.prepare(Some(1))?;
642    let row = Row::new(vec![
643        Value::Text("OK".to_string()),
644        Value::Text(message.to_string()),
645    ]);
646    writer.write_row(row)?;
647    writer.finish()?;
648    Ok(())
649}
650
651/// Execute a KV get command.
652fn execute_get<W: Write>(db: &Database, key: &str, writer: &mut StreamingWriter<W>) -> Result<()> {
653    let mut txn = db.begin(TxnMode::ReadOnly)?;
654    let result = txn.get(key.as_bytes())?;
655    txn.commit()?;
656
657    // Prepare writer with hint of 1 row
658    writer.prepare(Some(1))?;
659
660    match result {
661        Some(value) => {
662            // Try to interpret value as UTF-8 text, fallback to hex
663            let value_display = match std::str::from_utf8(&value) {
664                Ok(s) => Value::Text(s.to_string()),
665                Err(_) => Value::Bytes(value),
666            };
667
668            let row = Row::new(vec![Value::Text(key.to_string()), value_display]);
669            writer.write_row(row)?;
670        }
671        None => {
672            return Err(CliError::InvalidArgument(format!("Key not found: {}", key)));
673        }
674    }
675
676    writer.finish()?;
677    Ok(())
678}
679
680/// Execute a KV put command.
681fn execute_put<W: Write>(
682    db: &Database,
683    key: &str,
684    value: &str,
685    writer: &mut StreamingWriter<W>,
686) -> Result<()> {
687    let mut txn = db.begin(TxnMode::ReadWrite)?;
688    txn.put(key.as_bytes(), value.as_bytes())?;
689    txn.commit()?;
690
691    // Suppress status output in quiet mode
692    if !writer.is_quiet() {
693        writer.prepare(Some(1))?;
694        let row = Row::new(vec![
695            Value::Text("OK".to_string()),
696            Value::Text(format!("Stored key: {}", key)),
697        ]);
698        writer.write_row(row)?;
699        writer.finish()?;
700    }
701
702    Ok(())
703}
704
705/// Execute a KV delete command.
706fn execute_delete<W: Write>(
707    db: &Database,
708    key: &str,
709    writer: &mut StreamingWriter<W>,
710) -> Result<()> {
711    let mut txn = db.begin(TxnMode::ReadWrite)?;
712
713    // Check if key exists first
714    let exists = txn.get(key.as_bytes())?.is_some();
715
716    if !exists {
717        txn.rollback()?;
718        return Err(CliError::InvalidArgument(format!("Key not found: {}", key)));
719    }
720
721    txn.delete(key.as_bytes())?;
722    txn.commit()?;
723
724    // Suppress status output in quiet mode
725    if !writer.is_quiet() {
726        writer.prepare(Some(1))?;
727        let row = Row::new(vec![
728            Value::Text("OK".to_string()),
729            Value::Text(format!("Deleted key: {}", key)),
730        ]);
731        writer.write_row(row)?;
732        writer.finish()?;
733    }
734
735    Ok(())
736}
737
738/// Execute a KV list command.
739fn execute_list<W: Write>(
740    db: &Database,
741    prefix: Option<&str>,
742    writer: &mut StreamingWriter<W>,
743) -> Result<()> {
744    let mut txn = db.begin(TxnMode::ReadOnly)?;
745
746    let prefix_bytes = prefix.map(|p| p.as_bytes()).unwrap_or(b"");
747    let iter = txn.scan_prefix(prefix_bytes)?;
748
749    // Prepare writer before streaming (no hint since we don't know count ahead of time)
750    writer.prepare(None)?;
751
752    // Stream results directly without collecting to Vec
753    for (key, value) in iter {
754        // Try to interpret key and value as UTF-8 text
755        let key_display = match std::str::from_utf8(&key) {
756            Ok(s) => Value::Text(s.to_string()),
757            Err(_) => Value::Bytes(key),
758        };
759        let value_display = match std::str::from_utf8(&value) {
760            Ok(s) => Value::Text(s.to_string()),
761            Err(_) => Value::Bytes(value),
762        };
763
764        let row = Row::new(vec![key_display, value_display]);
765        match writer.write_row(row)? {
766            WriteStatus::LimitReached => break,
767            WriteStatus::Continue => {}
768        }
769    }
770
771    txn.commit()?;
772    writer.finish()?;
773    Ok(())
774}
775
776/// Create columns for KV output.
777pub fn kv_columns() -> Vec<Column> {
778    vec![
779        Column::new("key", DataType::Text),
780        Column::new("value", DataType::Text),
781    ]
782}
783
784/// Create columns for KV status output.
785pub fn kv_status_columns() -> Vec<Column> {
786    vec![
787        Column::new("status", DataType::Text),
788        Column::new("message", DataType::Text),
789    ]
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795    use crate::output::jsonl::JsonlFormatter;
796
797    fn create_test_db() -> Database {
798        Database::open_in_memory().unwrap()
799    }
800
801    fn create_test_writer(output: &mut Vec<u8>) -> StreamingWriter<&mut Vec<u8>> {
802        let formatter = Box::new(JsonlFormatter::new());
803        let columns = kv_columns();
804        StreamingWriter::new(output, formatter, columns, None)
805    }
806
807    fn create_status_writer(output: &mut Vec<u8>) -> StreamingWriter<&mut Vec<u8>> {
808        let formatter = Box::new(JsonlFormatter::new());
809        let columns = kv_status_columns();
810        StreamingWriter::new(output, formatter, columns, None)
811    }
812
813    #[test]
814    fn test_put_and_get() {
815        let db = create_test_db();
816
817        // Put
818        let mut output = Vec::new();
819        {
820            let mut writer = create_status_writer(&mut output);
821            execute_put(&db, "test_key", "test_value", &mut writer).unwrap();
822        }
823
824        // Get
825        let mut output = Vec::new();
826        {
827            let mut writer = create_test_writer(&mut output);
828            execute_get(&db, "test_key", &mut writer).unwrap();
829        }
830
831        let result = String::from_utf8(output).unwrap();
832        assert!(result.contains("test_key"));
833        assert!(result.contains("test_value"));
834    }
835
836    #[test]
837    fn test_get_not_found() {
838        let db = create_test_db();
839
840        let mut output = Vec::new();
841        let mut writer = create_test_writer(&mut output);
842
843        let result = execute_get(&db, "nonexistent", &mut writer);
844        assert!(result.is_err());
845        assert!(matches!(result.unwrap_err(), CliError::InvalidArgument(_)));
846    }
847
848    #[test]
849    fn test_delete() {
850        let db = create_test_db();
851
852        // Put first
853        {
854            let mut output = Vec::new();
855            let mut writer = create_status_writer(&mut output);
856            execute_put(&db, "to_delete", "value", &mut writer).unwrap();
857        }
858
859        // Delete
860        {
861            let mut output = Vec::new();
862            let mut writer = create_status_writer(&mut output);
863            execute_delete(&db, "to_delete", &mut writer).unwrap();
864        }
865
866        // Verify deletion
867        {
868            let mut output = Vec::new();
869            let mut writer = create_test_writer(&mut output);
870            let result = execute_get(&db, "to_delete", &mut writer);
871            assert!(result.is_err());
872        }
873    }
874
875    #[test]
876    fn test_delete_not_found() {
877        let db = create_test_db();
878
879        let mut output = Vec::new();
880        let mut writer = create_status_writer(&mut output);
881
882        let result = execute_delete(&db, "nonexistent", &mut writer);
883        assert!(result.is_err());
884        assert!(matches!(result.unwrap_err(), CliError::InvalidArgument(_)));
885    }
886
887    #[test]
888    fn test_list_all() {
889        let db = create_test_db();
890
891        // Put some keys
892        {
893            let mut txn = db.begin(TxnMode::ReadWrite).unwrap();
894            txn.put(b"key1", b"value1").unwrap();
895            txn.put(b"key2", b"value2").unwrap();
896            txn.put(b"key3", b"value3").unwrap();
897            txn.commit().unwrap();
898        }
899
900        // List all
901        let mut output = Vec::new();
902        {
903            let mut writer = create_test_writer(&mut output);
904            execute_list(&db, None, &mut writer).unwrap();
905        }
906
907        let result = String::from_utf8(output).unwrap();
908        assert!(result.contains("key1"));
909        assert!(result.contains("key2"));
910        assert!(result.contains("key3"));
911    }
912
913    #[test]
914    fn test_list_with_prefix() {
915        let db = create_test_db();
916
917        // Put some keys
918        {
919            let mut txn = db.begin(TxnMode::ReadWrite).unwrap();
920            txn.put(b"user:1", b"Alice").unwrap();
921            txn.put(b"user:2", b"Bob").unwrap();
922            txn.put(b"item:1", b"Widget").unwrap();
923            txn.commit().unwrap();
924        }
925
926        // List with prefix
927        let mut output = Vec::new();
928        {
929            let mut writer = create_test_writer(&mut output);
930            execute_list(&db, Some("user:"), &mut writer).unwrap();
931        }
932
933        let result = String::from_utf8(output).unwrap();
934        assert!(result.contains("user:1"));
935        assert!(result.contains("user:2"));
936        assert!(!result.contains("item:1"));
937    }
938
939    #[test]
940    fn test_list_empty() {
941        let db = create_test_db();
942
943        let mut output = Vec::new();
944        {
945            let mut writer = create_test_writer(&mut output);
946            execute_list(&db, None, &mut writer).unwrap();
947        }
948
949        // Should complete without error even with no results
950        let result = String::from_utf8(output).unwrap();
951        // Empty output is fine
952        assert!(result.is_empty() || result.lines().count() == 0);
953    }
954}