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};
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    /// Disable lexical index
48    #[arg(long = "no-lex", action = ArgAction::SetTrue)]
49    pub no_lex: bool,
50    /// Disable vector index
51    #[arg(long = "no-vector", aliases = ["no-vec"], action = ArgAction::SetTrue)]
52    pub no_vector: bool,
53}
54
55/// Arguments for the `open` subcommand
56#[derive(Args)]
57pub struct OpenArgs {
58    /// Path to the memory file to open
59    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
60    pub file: PathBuf,
61    /// Emit JSON instead of human-readable output
62    #[arg(long)]
63    pub json: bool,
64}
65
66/// Handler for `memvid create`
67pub fn handle_create(config: &CliConfig, args: CreateArgs) -> Result<()> {
68    if let Some(parent) = args.file.parent() {
69        if !parent.exists() {
70            fs::create_dir_all(parent)?;
71        }
72    }
73
74    // Capacity is determined by binding:
75    // - With --memory-id: capacity comes from dashboard memory (paid plan)
76    // - Without --memory-id: always free tier (1GB), regardless of API key
77    // This ensures paid capacity is only used for dashboard-tracked memories
78    let has_memory_id = args.memory_id.is_some();
79
80    // For initial creation, always use free tier capacity
81    // If --memory-id is provided, capacity will be upgraded after binding
82    let initial_capacity = args.size.unwrap_or(FREE_TIER_MAX_FILE_SIZE);
83    let mut capacity_bytes = initial_capacity.min(FREE_TIER_MAX_FILE_SIZE);
84
85    // Only show capacity warning if user explicitly requested more than free tier
86    // and didn't provide --memory-id
87    if let Some(requested) = args.size {
88        if requested > FREE_TIER_MAX_FILE_SIZE && !has_memory_id {
89            eprintln!(
90                "⚠️  Requested capacity {} exceeds free tier limit ({}).",
91                format_bytes(requested),
92                format_bytes(FREE_TIER_MAX_FILE_SIZE)
93            );
94            eprintln!("   To unlock more capacity, bind to a dashboard memory:");
95            eprintln!("   memvid create {} --memory-id <MEMORY_ID>", args.file.display());
96            eprintln!("   Or bind an existing file: memvid tickets sync {} --memory-id <MEMORY_ID>", args.file.display());
97            capacity_bytes = FREE_TIER_MAX_FILE_SIZE;
98        }
99    }
100
101    let lexical_enabled = !args.no_lex;
102    let vector_enabled = !args.no_vector;
103
104    let mut mem = Memvid::create(&args.file)?;
105    apply_capacity_override(&mut mem, capacity_bytes)?;
106    if lexical_enabled {
107        mem.enable_lex()?;
108    }
109
110    if vector_enabled {
111        mem.enable_vec()?;
112    }
113    mem.commit()?;
114
115    // If memory-id is provided, bind to the dashboard memory (this upgrades capacity)
116    let binding_info = if let Some(memory_id) = &args.memory_id {
117        match bind_to_dashboard_memory(config, &mut mem, &args.file, memory_id) {
118            Ok(info) => Some(info),
119            Err(e) => {
120                eprintln!("⚠️  Failed to bind to dashboard memory: {}", e);
121                eprintln!("   File created with free tier capacity. You can bind later with:");
122                eprintln!("   memvid tickets sync {} --memory-id {}", args.file.display(), memory_id);
123                None
124            }
125        }
126    } else {
127        None
128    };
129
130    let stats = mem.stats()?;
131
132    // Format output with next steps
133    let filename = args.file.display();
134    println!("✓ Created memory at {}", filename);
135    if let Some((bound_id, bound_capacity)) = &binding_info {
136        println!("  Bound to: {}", bound_id);
137        println!("  Capacity: {} (from dashboard)", format_bytes(*bound_capacity));
138    } else {
139        println!("  Tier: Free");
140        println!(
141            "  Capacity: {} ({} bytes)",
142            format_bytes(stats.capacity_bytes),
143            stats.capacity_bytes
144        );
145    }
146    println!("  Size: {}", format_bytes(stats.size_bytes));
147    println!(
148        "  Indexes: {} | {}",
149        if lexical_enabled { "lexical" } else { "no-lex" },
150        if vector_enabled { "vector" } else { "no-vec" }
151    );
152    println!();
153    println!("Next steps:");
154    println!("  memvid put {} --input <file>     # Add content", filename);
155    println!("  memvid find {} --query <text>    # Search", filename);
156    println!("  memvid stats {}                  # View stats", filename);
157    println!();
158    if binding_info.is_none() {
159        println!("Tip: Bind to a dashboard memory to unlock your plan's capacity:");
160        println!("     memvid tickets sync {} --memory-id <MEMORY_ID>", filename);
161    }
162    println!("Documentation: https://docs.memvid.com/cli/tickets-and-capacity");
163    Ok(())
164}
165
166/// Bind a memory file to a dashboard memory, syncing capacity ticket
167pub fn bind_to_dashboard_memory(
168    config: &CliConfig,
169    mem: &mut Memvid,
170    file_path: &PathBuf,
171    memory_id: &MemoryId,
172) -> Result<(String, u64)> {
173    use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
174    use chrono::Utc;
175    use memvid_core::types::MemoryBinding;
176    use memvid_core::verify_ticket_signature;
177
178    use crate::api::{fetch_ticket as api_fetch_ticket, register_file, RegisterFileRequest};
179    use crate::ticket_cache::{store as store_cached_ticket, CachedTicket};
180
181    // Require API key for binding
182    if config.api_key.is_none() {
183        anyhow::bail!("API key required for dashboard binding. Set MEMVID_API_KEY.");
184    }
185
186    let pubkey = config
187        .ticket_pubkey
188        .as_ref()
189        .ok_or_else(|| anyhow::anyhow!("Ticket verification key not available"))?;
190
191    // Fetch ticket from API
192    let response = api_fetch_ticket(config, memory_id)
193        .context("failed to fetch ticket from dashboard")?;
194
195    // Verify signature
196    let signature_bytes = BASE64_STANDARD
197        .decode(response.payload.signature.as_bytes())
198        .context("ticket signature is not valid base64")?;
199
200    verify_ticket_signature(
201        pubkey,
202        memory_id,
203        &response.payload.issuer,
204        response.payload.sequence,
205        response.payload.expires_in,
206        response.payload.capacity_bytes,
207        &signature_bytes,
208    )
209    .context("ticket signature verification failed")?;
210
211    // Create ticket
212    let mut ticket = Ticket::new(&response.payload.issuer, response.payload.sequence)
213        .expires_in_secs(response.payload.expires_in);
214    if let Some(capacity) = response.payload.capacity_bytes {
215        ticket = ticket.capacity_bytes(capacity);
216    }
217
218    // Create memory binding
219    let binding = MemoryBinding {
220        memory_id: **memory_id,
221        memory_name: response.payload.issuer.clone(),
222        bound_at: Utc::now(),
223        api_url: config.api_url.clone(),
224    };
225
226    // Register file with dashboard FIRST to check for duplicate bindings
227    let api_key = config.api_key.as_deref()
228        .ok_or_else(|| anyhow::anyhow!("API key required for file registration"))?;
229    let abs_path = std::fs::canonicalize(file_path)
230        .unwrap_or_else(|_| file_path.clone());
231    let file_metadata = std::fs::metadata(file_path)?;
232    let file_name = file_path
233        .file_name()
234        .and_then(|n| n.to_str())
235        .unwrap_or("unknown.mv2");
236    let machine_id = hostname::get()
237        .map(|h| h.to_string_lossy().to_string())
238        .unwrap_or_else(|_| "unknown".to_string());
239
240    let abs_path_str = abs_path.to_string_lossy();
241    let register_request = RegisterFileRequest {
242        file_name,
243        file_path: &abs_path_str,
244        file_size: file_metadata.len() as i64,
245        machine_id: &machine_id,
246    };
247    // File registration will fail with 409 if memory is already bound to a different file
248    register_file(config, memory_id, &register_request, api_key)
249        .context("failed to register file with dashboard")?;
250
251    // Bind memory (stores both ticket and binding)
252    mem.bind_memory(binding, ticket)
253        .context("failed to bind memory")?;
254    mem.commit()?;
255
256    // Cache the ticket
257    let cache_entry = CachedTicket {
258        memory_id: **memory_id,
259        issuer: response.payload.issuer.clone(),
260        seq_no: response.payload.sequence,
261        expires_in: response.payload.expires_in,
262        capacity_bytes: response.payload.capacity_bytes,
263        signature: response.payload.signature.clone(),
264    };
265    store_cached_ticket(config, &cache_entry)?;
266
267    let capacity = mem.get_capacity();
268    Ok((memory_id.to_string(), capacity))
269}
270
271/// Handler for `memvid open`
272pub fn handle_open(_config: &CliConfig, args: OpenArgs) -> Result<()> {
273    let mem = open_read_only_mem(&args.file)?;
274    let stats = mem.stats()?;
275    if args.json {
276        println!("{}", serde_json::to_string_pretty(&stats)?);
277    } else {
278        println!("Memory: {}", args.file.display());
279        println!("Frames: {}", stats.frame_count);
280        println!("Size: {} bytes", stats.size_bytes);
281        println!("Tier: {:?}", stats.tier);
282        println!(
283            "Indices → lex: {}, vec: {}, time: {}",
284            yes_no(stats.has_lex_index),
285            yes_no(stats.has_vec_index),
286            yes_no(stats.has_time_index)
287        );
288        if let Some(seq) = stats.seq_no {
289            println!("Ticket sequence: {seq}");
290        }
291    }
292    Ok(())
293}
294
295// Helper functions
296
297pub fn apply_capacity_override(mem: &mut Memvid, capacity_bytes: u64) -> Result<()> {
298    let current = mem.current_ticket();
299    if current.capacity_bytes == capacity_bytes {
300        return Ok(());
301    }
302
303    let seq = current.seq_no.saturating_add(1).max(1);
304    let mut ticket = Ticket::new(current.issuer.clone(), seq).capacity_bytes(capacity_bytes);
305    if current.expires_in_secs != 0 {
306        ticket = ticket.expires_in_secs(current.expires_in_secs);
307    }
308    apply_ticket_with_warning(mem, ticket)?;
309    Ok(())
310}
311
312pub fn apply_ticket_with_warning(mem: &mut Memvid, ticket: Ticket) -> Result<()> {
313    let before = mem.stats()?.capacity_bytes;
314    mem.apply_ticket(ticket)?;
315    let after = mem.stats()?.capacity_bytes;
316    if after < before {
317        println!(
318            "Warning: capacity reduced from {} to {}",
319            format_bytes(before),
320            format_bytes(after)
321        );
322    }
323    Ok(())
324}