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