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!(
135            "Following: {} relationships (up to {} hops)",
136            link, args.hops
137        );
138        println!();
139
140        if results.is_empty() {
141            println!("No {} relationships found.", link);
142        } else {
143            for result in &results {
144                println!(
145                    "  → {} ({}, confidence: {:.0}%)",
146                    result.node,
147                    result.kind.as_str(),
148                    result.confidence * 100.0
149                );
150                if !result.frame_ids.is_empty() {
151                    println!("    Frames: {:?}", result.frame_ids);
152                }
153            }
154        }
155    }
156
157    Ok(())
158}
159
160/// Handle follow entities command
161fn handle_follow_entities(args: FollowEntitiesArgs) -> Result<()> {
162    let mem = Memvid::open(&args.file)?;
163
164    if !mem.has_logic_mesh() {
165        bail!(
166            "No Logic-Mesh found in {}. Run `memvid put --logic-mesh` to build the graph.",
167            args.file.display()
168        );
169    }
170
171    let mesh = mem.logic_mesh();
172
173    // Filter entities by kind if specified
174    let mut entities: Vec<_> = mesh.nodes.iter().collect();
175
176    if let Some(ref kind_filter) = args.kind {
177        let kind_lower = kind_filter.to_lowercase();
178        entities.retain(|node| node.kind.as_str() == kind_lower);
179    }
180
181    // Filter by query if specified
182    if let Some(ref query) = args.query {
183        let query_lower = query.to_lowercase();
184        entities.retain(|node| {
185            node.display_name.to_lowercase().contains(&query_lower)
186                || node.canonical_name.contains(&query_lower)
187        });
188    }
189
190    // Sort by display name for consistent output
191    entities.sort_by(|a, b| a.display_name.cmp(&b.display_name));
192
193    // Apply limit
194    let limited: Vec<_> = entities.into_iter().take(args.limit).collect();
195
196    if args.json {
197        #[derive(Serialize)]
198        struct EntityOutput {
199            name: String,
200            kind: String,
201            confidence: f32,
202            frame_count: usize,
203            frame_ids: Vec<u64>,
204        }
205        let output: Vec<EntityOutput> = limited
206            .iter()
207            .map(|node| EntityOutput {
208                name: node.display_name.clone(),
209                kind: node.kind.as_str().to_string(),
210                confidence: node.confidence_f32(),
211                frame_count: node.frame_ids.len(),
212                frame_ids: node.frame_ids.clone(),
213            })
214            .collect();
215        println!("{}", serde_json::to_string_pretty(&output)?);
216    } else {
217        println!(
218            "Entities in Logic-Mesh (showing {} of {})",
219            limited.len(),
220            mesh.nodes.len()
221        );
222        println!();
223        for node in &limited {
224            println!(
225                "  {} ({}, confidence: {:.0}%, {} frames)",
226                node.display_name,
227                node.kind.as_str(),
228                node.confidence_f32() * 100.0,
229                node.frame_ids.len()
230            );
231        }
232        if limited.len() < mesh.nodes.len() {
233            println!();
234            println!("Use --limit to show more entities, or --query to filter.");
235        }
236    }
237
238    Ok(())
239}
240
241/// Handle follow stats command
242fn handle_follow_stats(args: FollowStatsArgs) -> Result<()> {
243    let mem = Memvid::open(&args.file)?;
244
245    if !mem.has_logic_mesh() {
246        if args.json {
247            println!(r#"{{"error": "No Logic-Mesh found"}}"#);
248        } else {
249            println!("No Logic-Mesh found in {}", args.file.display());
250            println!("Run `memvid put --logic-mesh` to build the graph.");
251        }
252        return Ok(());
253    }
254
255    let stats = mem.logic_mesh_stats();
256    let manifest = mem.logic_mesh_manifest();
257
258    if args.json {
259        #[derive(Serialize)]
260        struct MeshStatsOutput {
261            node_count: usize,
262            edge_count: usize,
263            entity_kinds: std::collections::HashMap<String, usize>,
264            link_types: std::collections::HashMap<String, usize>,
265            #[serde(skip_serializing_if = "Option::is_none")]
266            bytes_offset: Option<u64>,
267            #[serde(skip_serializing_if = "Option::is_none")]
268            bytes_length: Option<u64>,
269        }
270        let output = MeshStatsOutput {
271            node_count: stats.node_count,
272            edge_count: stats.edge_count,
273            entity_kinds: stats.entity_kinds,
274            link_types: stats.link_types,
275            bytes_offset: manifest.map(|m| m.bytes_offset),
276            bytes_length: manifest.map(|m| m.bytes_length),
277        };
278        println!("{}", serde_json::to_string_pretty(&output)?);
279    } else {
280        println!("Logic-Mesh Statistics");
281        println!("=====================");
282        println!("  Nodes (entities):  {}", stats.node_count);
283        println!("  Edges (relations): {}", stats.edge_count);
284        if !stats.entity_kinds.is_empty() {
285            println!();
286            println!("  Entity Kinds:");
287            let mut kinds: Vec<_> = stats.entity_kinds.iter().collect();
288            kinds.sort_by(|a, b| b.1.cmp(a.1));
289            for (kind, count) in kinds {
290                println!("    {}: {}", kind, count);
291            }
292        }
293        if !stats.link_types.is_empty() {
294            println!();
295            println!("  Relationship Types:");
296            let mut links: Vec<_> = stats.link_types.iter().collect();
297            links.sort_by(|a, b| b.1.cmp(a.1));
298            for (link, count) in links {
299                println!("    {}: {}", link, count);
300            }
301        }
302        if let Some(m) = manifest {
303            println!();
304            println!("  Storage offset:    {}", m.bytes_offset);
305            println!("  Storage size:      {} bytes", m.bytes_length);
306        }
307    }
308
309    Ok(())
310}
311
312#[cfg(test)]
313mod tests {
314    #[test]
315    fn test_direction_parsing() {
316        // Test that direction string parsing would work
317        let directions = vec!["outgoing", "incoming", "both"];
318        for d in directions {
319            assert!(!d.is_empty());
320        }
321    }
322}