memvid_cli/commands/
follow.rs

1//! Logic-Mesh follow command for graph traversal.
2//!
3//! This command allows traversing the entity-relationship graph
4//! to follow facts and relationships instead of relying on vector search.
5
6use anyhow::{bail, Result};
7use clap::{Args, Subcommand};
8use memvid_core::Memvid;
9use serde::Serialize;
10use std::path::PathBuf;
11
12use crate::config::CliConfig;
13
14/// Logic-Mesh graph traversal commands
15#[derive(Args)]
16pub struct FollowArgs {
17    #[command(subcommand)]
18    pub command: FollowCommand,
19}
20
21#[derive(Subcommand)]
22pub enum FollowCommand {
23    /// Follow relationships from an entity
24    Traverse(FollowTraverseArgs),
25    /// List all entities in the mesh
26    Entities(FollowEntitiesArgs),
27    /// Show statistics about the Logic-Mesh
28    Stats(FollowStatsArgs),
29}
30
31/// Arguments for the follow traverse command
32#[derive(Args)]
33pub struct FollowTraverseArgs {
34    /// Path to the .mv2 file
35    pub file: PathBuf,
36
37    /// Starting entity name (case-insensitive partial match)
38    #[arg(short, long)]
39    pub start: String,
40
41    /// Relationship type to follow (e.g., "manager", "member", "author")
42    #[arg(short, long)]
43    pub link: Option<String>,
44
45    /// Maximum hops to traverse (default: 2)
46    #[arg(long, default_value = "2")]
47    pub hops: usize,
48
49    /// Direction to traverse: "outgoing", "incoming", or "both" (default: "both")
50    #[arg(long, default_value = "both")]
51    pub direction: String,
52
53    /// Output as JSON
54    #[arg(long)]
55    pub json: bool,
56}
57
58/// Arguments for listing entities
59#[derive(Args)]
60pub struct FollowEntitiesArgs {
61    /// Path to the .mv2 file
62    pub file: PathBuf,
63
64    /// Filter by entity type (person, organization, project, etc.)
65    #[arg(short = 't', long)]
66    pub kind: Option<String>,
67
68    /// Search query to filter entities by name
69    #[arg(short, long)]
70    pub query: Option<String>,
71
72    /// Maximum number of entities to show
73    #[arg(short, long, default_value = "50")]
74    pub limit: usize,
75
76    /// Output as JSON
77    #[arg(long)]
78    pub json: bool,
79}
80
81/// Arguments for mesh statistics
82#[derive(Args)]
83pub struct FollowStatsArgs {
84    /// Path to the .mv2 file
85    pub file: PathBuf,
86
87    /// Output as JSON
88    #[arg(long)]
89    pub json: bool,
90}
91
92/// Handle follow commands
93pub fn handle_follow(_config: &CliConfig, args: FollowArgs) -> Result<()> {
94    match args.command {
95        FollowCommand::Traverse(traverse_args) => handle_follow_traverse(traverse_args),
96        FollowCommand::Entities(entities_args) => handle_follow_entities(entities_args),
97        FollowCommand::Stats(stats_args) => handle_follow_stats(stats_args),
98    }
99}
100
101/// Handle follow traverse command
102fn handle_follow_traverse(args: FollowTraverseArgs) -> Result<()> {
103    let mem = Memvid::open(&args.file)?;
104
105    // Check if Logic-Mesh exists
106    if !mem.has_logic_mesh() {
107        bail!(
108            "No Logic-Mesh found in {}. Run `memvid put --logic-mesh` to build the graph.",
109            args.file.display()
110        );
111    }
112
113    // Find the starting entity
114    let start_node = mem.find_entity(&args.start);
115    if start_node.is_none() {
116        bail!(
117            "Entity '{}' not found in Logic-Mesh. Use `memvid follow entities` to list available entities.",
118            args.start
119        );
120    }
121
122    let link = args.link.as_deref().unwrap_or("related");
123    let results = mem.follow(&args.start, link, args.hops);
124
125    if args.json {
126        println!("{}", serde_json::to_string_pretty(&results)?);
127    } else {
128        let start_node = start_node.unwrap();
129        println!(
130            "Starting from: {} ({})",
131            start_node.display_name,
132            start_node.kind.as_str()
133        );
134        println!("Following: {} relationships (up to {} hops)", link, args.hops);
135        println!();
136
137        if results.is_empty() {
138            println!("No {} relationships found.", link);
139        } else {
140            for result in &results {
141                println!(
142                    "  → {} ({}, confidence: {:.0}%)",
143                    result.node,
144                    result.kind.as_str(),
145                    result.confidence * 100.0
146                );
147                if !result.frame_ids.is_empty() {
148                    println!("    Frames: {:?}", result.frame_ids);
149                }
150            }
151        }
152    }
153
154    Ok(())
155}
156
157/// Handle follow entities command
158fn handle_follow_entities(args: FollowEntitiesArgs) -> Result<()> {
159    let mem = Memvid::open(&args.file)?;
160
161    if !mem.has_logic_mesh() {
162        bail!(
163            "No Logic-Mesh found in {}. Run `memvid put --logic-mesh` to build the graph.",
164            args.file.display()
165        );
166    }
167
168    let mesh = mem.logic_mesh();
169
170    // Filter entities by kind if specified
171    let mut entities: Vec<_> = mesh.nodes.iter().collect();
172
173    if let Some(ref kind_filter) = args.kind {
174        let kind_lower = kind_filter.to_lowercase();
175        entities.retain(|node| node.kind.as_str() == kind_lower);
176    }
177
178    // Filter by query if specified
179    if let Some(ref query) = args.query {
180        let query_lower = query.to_lowercase();
181        entities.retain(|node| {
182            node.display_name.to_lowercase().contains(&query_lower)
183                || node.canonical_name.contains(&query_lower)
184        });
185    }
186
187    // Sort by display name for consistent output
188    entities.sort_by(|a, b| a.display_name.cmp(&b.display_name));
189
190    // Apply limit
191    let limited: Vec<_> = entities.into_iter().take(args.limit).collect();
192
193    if args.json {
194        #[derive(Serialize)]
195        struct EntityOutput {
196            name: String,
197            kind: String,
198            confidence: f32,
199            frame_count: usize,
200            frame_ids: Vec<u64>,
201        }
202        let output: Vec<EntityOutput> = limited
203            .iter()
204            .map(|node| EntityOutput {
205                name: node.display_name.clone(),
206                kind: node.kind.as_str().to_string(),
207                confidence: node.confidence_f32(),
208                frame_count: node.frame_ids.len(),
209                frame_ids: node.frame_ids.clone(),
210            })
211            .collect();
212        println!("{}", serde_json::to_string_pretty(&output)?);
213    } else {
214        println!(
215            "Entities in Logic-Mesh (showing {} of {})",
216            limited.len(),
217            mesh.nodes.len()
218        );
219        println!();
220        for node in &limited {
221            println!(
222                "  {} ({}, confidence: {:.0}%, {} frames)",
223                node.display_name,
224                node.kind.as_str(),
225                node.confidence_f32() * 100.0,
226                node.frame_ids.len()
227            );
228        }
229        if limited.len() < mesh.nodes.len() {
230            println!();
231            println!("Use --limit to show more entities, or --query to filter.");
232        }
233    }
234
235    Ok(())
236}
237
238/// Handle follow stats command
239fn handle_follow_stats(args: FollowStatsArgs) -> Result<()> {
240    let mem = Memvid::open(&args.file)?;
241
242    if !mem.has_logic_mesh() {
243        if args.json {
244            println!(r#"{{"error": "No Logic-Mesh found"}}"#);
245        } else {
246            println!("No Logic-Mesh found in {}", args.file.display());
247            println!("Run `memvid put --logic-mesh` to build the graph.");
248        }
249        return Ok(());
250    }
251
252    let stats = mem.logic_mesh_stats();
253    let manifest = mem.logic_mesh_manifest();
254
255    if args.json {
256        #[derive(Serialize)]
257        struct MeshStatsOutput {
258            node_count: usize,
259            edge_count: usize,
260            entity_kinds: std::collections::HashMap<String, usize>,
261            link_types: std::collections::HashMap<String, usize>,
262            #[serde(skip_serializing_if = "Option::is_none")]
263            bytes_offset: Option<u64>,
264            #[serde(skip_serializing_if = "Option::is_none")]
265            bytes_length: Option<u64>,
266        }
267        let output = MeshStatsOutput {
268            node_count: stats.node_count,
269            edge_count: stats.edge_count,
270            entity_kinds: stats.entity_kinds,
271            link_types: stats.link_types,
272            bytes_offset: manifest.map(|m| m.bytes_offset),
273            bytes_length: manifest.map(|m| m.bytes_length),
274        };
275        println!("{}", serde_json::to_string_pretty(&output)?);
276    } else {
277        println!("Logic-Mesh Statistics");
278        println!("=====================");
279        println!("  Nodes (entities):  {}", stats.node_count);
280        println!("  Edges (relations): {}", stats.edge_count);
281        if !stats.entity_kinds.is_empty() {
282            println!();
283            println!("  Entity Kinds:");
284            let mut kinds: Vec<_> = stats.entity_kinds.iter().collect();
285            kinds.sort_by(|a, b| b.1.cmp(a.1));
286            for (kind, count) in kinds {
287                println!("    {}: {}", kind, count);
288            }
289        }
290        if !stats.link_types.is_empty() {
291            println!();
292            println!("  Relationship Types:");
293            let mut links: Vec<_> = stats.link_types.iter().collect();
294            links.sort_by(|a, b| b.1.cmp(a.1));
295            for (link, count) in links {
296                println!("    {}: {}", link, count);
297            }
298        }
299        if let Some(m) = manifest {
300            println!();
301            println!("  Storage offset:    {}", m.bytes_offset);
302            println!("  Storage size:      {} bytes", m.bytes_length);
303        }
304    }
305
306    Ok(())
307}
308
309#[cfg(test)]
310mod tests {
311    #[test]
312    fn test_direction_parsing() {
313        // Test that direction string parsing would work
314        let directions = vec!["outgoing", "incoming", "both"];
315        for d in directions {
316            assert!(!d.is_empty());
317        }
318    }
319}