memvid_cli/commands/
creation.rs

1//! Creation command handlers (create, open)
2
3use std::fs;
4use std::path::PathBuf;
5
6use anyhow::{Context, Result};
7use clap::{ArgAction, Args, ValueEnum};
8use memvid_core::{Memvid, Ticket};
9
10use crate::commands::tickets::MemoryId;
11use crate::config::CliConfig;
12use crate::utils::{format_bytes, open_read_only_mem, parse_size, yes_no, FREE_TIER_MAX_FILE_SIZE, MIN_FILE_SIZE};
13
14/// Tier argument for CLI
15#[derive(Clone, Copy, Debug, ValueEnum)]
16pub enum TierArg {
17    Free,
18    Dev,
19    Enterprise,
20}
21
22impl From<TierArg> for memvid_core::Tier {
23    fn from(value: TierArg) -> Self {
24        match value {
25            TierArg::Free => memvid_core::Tier::Free,
26            TierArg::Dev => memvid_core::Tier::Dev,
27            TierArg::Enterprise => memvid_core::Tier::Enterprise,
28        }
29    }
30}
31
32/// Arguments for the `create` subcommand
33#[derive(Args)]
34pub struct CreateArgs {
35    /// Path to the memory file to create
36    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
37    pub file: PathBuf,
38    /// Tier to apply when creating the memory
39    #[arg(long, value_enum)]
40    pub tier: Option<TierArg>,
41    /// Set the maximum memory size (e.g. 512MB); defaults to plan capacity
42    #[arg(long = "size", alias = "capacity", value_name = "SIZE", value_parser = parse_size)]
43    pub size: Option<u64>,
44    /// Bind to a dashboard memory ID (UUID or 24-char ObjectId) for capacity and tracking
45    #[arg(long = "memory-id", value_name = "ID")]
46    pub memory_id: Option<MemoryId>,
47    /// Use a named memory from config (e.g., --memory work uses memory.work)
48    #[arg(long = "memory", value_name = "NAME")]
49    pub memory_name: Option<String>,
50    /// Disable lexical index
51    #[arg(long = "no-lex", action = ArgAction::SetTrue)]
52    pub no_lex: bool,
53    /// Disable vector index
54    #[arg(long = "no-vector", aliases = ["no-vec"], action = ArgAction::SetTrue)]
55    pub no_vector: bool,
56}
57
58/// Arguments for the `open` subcommand
59#[derive(Args)]
60pub struct OpenArgs {
61    /// Path to the memory file to open
62    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
63    pub file: PathBuf,
64    /// Emit JSON instead of human-readable output
65    #[arg(long)]
66    pub json: bool,
67}
68
69/// Handler for `memvid create`
70pub fn handle_create(config: &CliConfig, args: CreateArgs) -> Result<()> {
71    if let Some(parent) = args.file.parent() {
72        if !parent.exists() {
73            fs::create_dir_all(parent)?;
74        }
75    }
76
77    // Capacity is determined by binding:
78    // - With --memory-id: capacity comes from dashboard memory (paid plan)
79    // - Without --memory-id: always free tier (1GB), regardless of API key
80    // This ensures paid capacity is only used for dashboard-tracked memories
81    //
82    // Memory ID priority: --memory-id flag > --memory name lookup > config default > none
83    let effective_memory_id: Option<MemoryId> = args.memory_id.clone().or_else(|| {
84        // If --memory <name> is provided, look it up in config
85        if let Some(ref name) = args.memory_name {
86            if let Ok(persistent_config) = crate::commands::config::PersistentConfig::load() {
87                if let Some(id) = persistent_config.get_memory(name) {
88                    return id.parse::<MemoryId>().ok();
89                } else {
90                    eprintln!("⚠️  Memory '{}' not found in config. Available memories:", name);
91                    for (mem_name, _) in &persistent_config.memory {
92                        eprintln!("   - {}", mem_name);
93                    }
94                    eprintln!("   Set it with: memvid config set memory.{} <MEMORY_ID>", name);
95                }
96            }
97            return None;
98        }
99        // Fall back to config default (memory.default or legacy memory_id)
100        config.memory_id.as_ref().and_then(|id| {
101            id.parse::<MemoryId>().ok()
102        })
103    });
104    let has_memory_id = effective_memory_id.is_some();
105
106    // Validate --size if provided
107    if let Some(requested) = args.size {
108        // Check minimum size
109        if requested < MIN_FILE_SIZE {
110            anyhow::bail!(
111                "Requested capacity {} is below minimum ({}).\n\
112                 Minimum memory size is {} to ensure reasonable capacity for basic usage.",
113                format_bytes(requested),
114                format_bytes(MIN_FILE_SIZE),
115                format_bytes(MIN_FILE_SIZE)
116            );
117        }
118
119        // Check maximum size (only for free tier without memory-id)
120        if requested > FREE_TIER_MAX_FILE_SIZE && !has_memory_id {
121            anyhow::bail!(
122                "Requested capacity {} exceeds free tier limit ({}).\n\
123                 To unlock more capacity, bind to a dashboard memory:\n\
124                   memvid create {} --memory-id <MEMORY_ID>\n\
125                 Or bind an existing file:\n\
126                   memvid tickets sync {} --memory-id <MEMORY_ID>",
127                format_bytes(requested),
128                format_bytes(FREE_TIER_MAX_FILE_SIZE),
129                args.file.display(),
130                args.file.display()
131            );
132        }
133    }
134
135    // For initial creation, always use free tier capacity
136    // If --memory-id is provided, capacity will be upgraded after binding
137    let initial_capacity = args.size.unwrap_or(FREE_TIER_MAX_FILE_SIZE);
138    let capacity_bytes = initial_capacity.min(FREE_TIER_MAX_FILE_SIZE);
139
140    let lexical_enabled = !args.no_lex;
141    let vector_enabled = !args.no_vector;
142
143    let mut mem = Memvid::create(&args.file)?;
144    apply_capacity_override(&mut mem, capacity_bytes)?;
145    if lexical_enabled {
146        mem.enable_lex()?;
147    }
148
149    if vector_enabled {
150        mem.enable_vec()?;
151    }
152    mem.commit()?;
153
154    // If memory-id is provided (CLI or config), bind to the dashboard memory (this upgrades capacity)
155    let binding_info = if let Some(memory_id) = &effective_memory_id {
156        match bind_to_dashboard_memory(config, &mut mem, &args.file, memory_id) {
157            Ok(info) => Some(info),
158            Err(e) => {
159                // Show root cause if available for better error messages
160                let root_cause = e.root_cause();
161                eprintln!("⚠️  Failed to bind to dashboard memory: {}", root_cause);
162                eprintln!("   File created with free tier capacity. You can bind later with:");
163                eprintln!("   memvid tickets sync {} --memory-id {}", args.file.display(), memory_id);
164                None
165            }
166        }
167    } else {
168        None
169    };
170
171    let stats = mem.stats()?;
172
173    // Format output with next steps
174    let filename = args.file.display();
175    println!("✓ Created memory at {}", filename);
176    if let Some((bound_id, bound_capacity)) = &binding_info {
177        println!("  Bound to: {}", bound_id);
178        println!("  Capacity: {} (from dashboard)", format_bytes(*bound_capacity));
179    } else {
180        println!("  Tier: Free");
181        println!(
182            "  Capacity: {} ({} bytes)",
183            format_bytes(stats.capacity_bytes),
184            stats.capacity_bytes
185        );
186    }
187    println!("  Size: {}", format_bytes(stats.size_bytes));
188    println!(
189        "  Indexes: {} | {}",
190        if lexical_enabled { "lexical" } else { "no-lex" },
191        if vector_enabled { "vector" } else { "no-vec" }
192    );
193    println!();
194    println!("Next steps:");
195    println!("  memvid put {} --input <file>     # Add content", filename);
196    println!("  memvid find {} --query <text>    # Search", filename);
197    println!("  memvid stats {}                  # View stats", filename);
198    println!();
199    if binding_info.is_none() {
200        println!("Tip: Bind to a dashboard memory to unlock your plan's capacity:");
201        println!("     memvid tickets sync {} --memory-id <MEMORY_ID>", filename);
202    }
203    println!("Documentation: https://docs.memvid.com/cli/tickets-and-capacity");
204    Ok(())
205}
206
207/// Bind a memory file to a dashboard memory, syncing capacity ticket
208pub fn bind_to_dashboard_memory(
209    config: &CliConfig,
210    mem: &mut Memvid,
211    file_path: &PathBuf,
212    memory_id: &MemoryId,
213) -> Result<(String, u64)> {
214    use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
215    use chrono::Utc;
216    use memvid_core::types::MemoryBinding;
217    use memvid_core::verify_ticket_signature;
218
219    use crate::api::{fetch_ticket as api_fetch_ticket, register_file, RegisterFileRequest};
220    use crate::ticket_cache::{store as store_cached_ticket, CachedTicket};
221
222    // Require API key for binding
223    if config.api_key.is_none() {
224        anyhow::bail!("API key required for dashboard binding. Set MEMVID_API_KEY.");
225    }
226
227    let pubkey = config
228        .ticket_pubkey
229        .as_ref()
230        .ok_or_else(|| anyhow::anyhow!("Ticket verification key not available"))?;
231
232    // Fetch ticket from API
233    let response = api_fetch_ticket(config, memory_id)
234        .context("failed to fetch ticket from dashboard")?;
235
236    // Verify signature
237    let signature_bytes = BASE64_STANDARD
238        .decode(response.payload.signature.as_bytes())
239        .context("ticket signature is not valid base64")?;
240
241    verify_ticket_signature(
242        pubkey,
243        memory_id,
244        &response.payload.issuer,
245        response.payload.sequence,
246        response.payload.expires_in,
247        response.payload.capacity_bytes,
248        &signature_bytes,
249    )
250    .context("ticket signature verification failed")?;
251
252    // Create ticket
253    let mut ticket = Ticket::new(&response.payload.issuer, response.payload.sequence)
254        .expires_in_secs(response.payload.expires_in);
255    if let Some(capacity) = response.payload.capacity_bytes {
256        ticket = ticket.capacity_bytes(capacity);
257    }
258
259    // Create memory binding
260    let binding = MemoryBinding {
261        memory_id: **memory_id,
262        memory_name: response.payload.issuer.clone(),
263        bound_at: Utc::now(),
264        api_url: config.api_url.clone(),
265    };
266
267    // Register file with dashboard FIRST to check for duplicate bindings
268    let api_key = config.api_key.as_deref()
269        .ok_or_else(|| anyhow::anyhow!("API key required for file registration"))?;
270    let abs_path = std::fs::canonicalize(file_path)
271        .unwrap_or_else(|_| file_path.clone());
272    let file_metadata = std::fs::metadata(file_path)?;
273    let file_name = file_path
274        .file_name()
275        .and_then(|n| n.to_str())
276        .unwrap_or("unknown.mv2");
277    let machine_id = hostname::get()
278        .map(|h| h.to_string_lossy().to_string())
279        .unwrap_or_else(|_| "unknown".to_string());
280
281    let abs_path_str = abs_path.to_string_lossy();
282    let register_request = RegisterFileRequest {
283        file_name,
284        file_path: &abs_path_str,
285        file_size: file_metadata.len() as i64,
286        machine_id: &machine_id,
287    };
288    // File registration will fail with 409 if memory is already bound to a different file
289    register_file(config, memory_id, &register_request, api_key)
290        .context("failed to register file with dashboard")?;
291
292    // Bind memory (stores both ticket and binding)
293    mem.bind_memory(binding, ticket)
294        .context("failed to bind memory")?;
295    mem.commit()?;
296
297    // Cache the ticket
298    let cache_entry = CachedTicket {
299        memory_id: **memory_id,
300        issuer: response.payload.issuer.clone(),
301        seq_no: response.payload.sequence,
302        expires_in: response.payload.expires_in,
303        capacity_bytes: response.payload.capacity_bytes,
304        signature: response.payload.signature.clone(),
305    };
306    store_cached_ticket(config, &cache_entry)?;
307
308    let capacity = mem.get_capacity();
309    Ok((memory_id.to_string(), capacity))
310}
311
312/// Handler for `memvid open`
313pub fn handle_open(_config: &CliConfig, args: OpenArgs) -> Result<()> {
314    let mem = open_read_only_mem(&args.file)?;
315    let stats = mem.stats()?;
316    if args.json {
317        println!("{}", serde_json::to_string_pretty(&stats)?);
318    } else {
319        println!("Memory: {}", args.file.display());
320        println!("Frames: {}", stats.frame_count);
321        println!("Size: {} bytes", stats.size_bytes);
322        println!("Tier: {:?}", stats.tier);
323        println!(
324            "Indices → lex: {}, vec: {}, time: {}",
325            yes_no(stats.has_lex_index),
326            yes_no(stats.has_vec_index),
327            yes_no(stats.has_time_index)
328        );
329        if let Some(seq) = stats.seq_no {
330            println!("Ticket sequence: {seq}");
331        }
332    }
333    Ok(())
334}
335
336// Helper functions
337
338pub fn apply_capacity_override(mem: &mut Memvid, capacity_bytes: u64) -> Result<()> {
339    let current = mem.current_ticket();
340    if current.capacity_bytes == capacity_bytes {
341        return Ok(());
342    }
343
344    let seq = current.seq_no.saturating_add(1).max(1);
345    let mut ticket = Ticket::new(current.issuer.clone(), seq).capacity_bytes(capacity_bytes);
346    if current.expires_in_secs != 0 {
347        ticket = ticket.expires_in_secs(current.expires_in_secs);
348    }
349    apply_ticket_with_warning(mem, ticket)?;
350    Ok(())
351}
352
353pub fn apply_ticket_with_warning(mem: &mut Memvid, ticket: Ticket) -> Result<()> {
354    let before = mem.stats()?.capacity_bytes;
355    mem.apply_ticket(ticket)?;
356    let after = mem.stats()?.capacity_bytes;
357    if after < before {
358        println!(
359            "Warning: capacity reduced from {} to {}",
360            format_bytes(before),
361            format_bytes(after)
362        );
363    }
364    Ok(())
365}