Skip to main content

fabryk_cli/
graph_handlers.rs

1//! Handler functions for graph CLI commands.
2//!
3//! These functions implement the logic behind `graph validate`, `graph stats`,
4//! `graph query`, and `graph build` (with a domain-provided extractor).
5
6use fabryk_core::traits::ConfigProvider;
7use fabryk_core::{Error, Result};
8use fabryk_graph::{
9    GraphBuilder, GraphData, GraphExtractor, GraphMetadata, compute_stats, load_graph,
10    neighborhood, prerequisites_sorted, save_graph, shortest_path, validate_graph,
11};
12use std::path::PathBuf;
13
14// ============================================================================
15// Option types
16// ============================================================================
17
18/// Options for graph build operations.
19#[derive(Debug, Clone)]
20pub struct BuildOptions {
21    /// Output file path (defaults to data/graphs/graph.json under base_path).
22    pub output: Option<String>,
23    /// If true, show what would be built without writing.
24    pub dry_run: bool,
25}
26
27/// Options for graph query operations.
28#[derive(Debug, Clone)]
29pub struct QueryOptions {
30    /// Node ID to query.
31    pub id: String,
32    /// Type of query: "related", "prerequisites", or "path".
33    pub query_type: String,
34    /// Target node for path queries.
35    pub to: Option<String>,
36}
37
38// ============================================================================
39// Helper: resolve graph path
40// ============================================================================
41
42/// Resolve the default graph file path from config.
43fn graph_path<C: ConfigProvider>(config: &C) -> Result<PathBuf> {
44    let base = config.base_path()?;
45    Ok(base.join("data").join("graphs").join("graph.json"))
46}
47
48// ============================================================================
49// Handlers
50// ============================================================================
51
52/// Build a knowledge graph using the provided extractor.
53///
54/// Two-phase build: discover content, build graph, optionally save.
55pub async fn handle_build<C: ConfigProvider, E: GraphExtractor>(
56    config: &C,
57    extractor: E,
58    options: BuildOptions,
59) -> Result<()> {
60    let content_path = config.content_path("concepts")?;
61    let output_path = match options.output {
62        Some(ref p) => PathBuf::from(p),
63        None => graph_path(config)?,
64    };
65
66    println!("Building graph from: {}", content_path.display());
67
68    let (graph, stats) = GraphBuilder::new(extractor)
69        .with_content_path(&content_path)
70        .build()
71        .await?;
72
73    println!("Graph built:");
74    println!("  Nodes:           {}", stats.nodes_created);
75    println!("  Edges:           {}", stats.edges_created);
76    println!("  Files processed: {}", stats.files_processed);
77    println!("  Files skipped:   {}", stats.files_skipped);
78    if !stats.errors.is_empty() {
79        println!("  Errors:          {}", stats.errors.len());
80    }
81    if !stats.dangling_refs.is_empty() {
82        println!("  Dangling refs:   {}", stats.dangling_refs.len());
83    }
84
85    if options.dry_run {
86        println!("\nDry run — graph not saved.");
87    } else {
88        // Ensure parent directory exists
89        if let Some(parent) = output_path.parent() {
90            std::fs::create_dir_all(parent).map_err(|e| Error::io_with_path(e, parent))?;
91        }
92
93        let metadata = GraphMetadata {
94            source_file_count: Some(stats.files_processed),
95            ..Default::default()
96        };
97
98        save_graph(&graph, &output_path, Some(metadata))?;
99        println!("\nGraph saved to: {}", output_path.display());
100    }
101
102    Ok(())
103}
104
105/// Validate graph integrity.
106pub async fn handle_validate<C: ConfigProvider>(config: &C) -> Result<()> {
107    let path = graph_path(config)?;
108    let graph = load_graph_or_error(&path)?;
109
110    let result = validate_graph(&graph);
111
112    if result.valid {
113        println!("Graph is valid.");
114    } else {
115        println!("Graph has validation issues:");
116    }
117
118    for error in &result.errors {
119        println!("  ERROR [{}]: {}", error.code, error.message);
120        for node in &error.nodes {
121            println!("    - {node}");
122        }
123        for edge in &error.edges {
124            println!("    - {edge}");
125        }
126    }
127
128    for warning in &result.warnings {
129        println!("  WARN  [{}]: {}", warning.code, warning.message);
130        for node in &warning.nodes {
131            println!("    - {node}");
132        }
133    }
134
135    println!(
136        "\nSummary: {} error(s), {} warning(s)",
137        result.errors.len(),
138        result.warnings.len()
139    );
140
141    if result.valid {
142        Ok(())
143    } else {
144        Err(Error::operation(format!(
145            "Graph validation failed with {} error(s)",
146            result.errors.len()
147        )))
148    }
149}
150
151/// Show graph statistics.
152pub async fn handle_stats<C: ConfigProvider>(config: &C) -> Result<()> {
153    let path = graph_path(config)?;
154    let graph = load_graph_or_error(&path)?;
155
156    let stats = compute_stats(&graph);
157
158    println!("Graph Statistics");
159    println!("================");
160    println!("Nodes:          {}", stats.node_count);
161    println!("  Canonical:    {}", stats.canonical_count);
162    println!("  Variants:     {}", stats.variant_count);
163    println!("  Orphans:      {}", stats.orphan_count);
164    println!("Edges:          {}", stats.edge_count);
165    println!("Avg degree:     {:.2}", stats.avg_degree);
166    println!("Max in-degree:  {}", stats.max_in_degree);
167    println!("Max out-degree: {}", stats.max_out_degree);
168
169    if let Some(ref node_id) = stats.most_depended_on {
170        println!(
171            "Most depended on: {node_id} (in-degree: {})",
172            stats.max_in_degree
173        );
174    }
175    if let Some(ref node_id) = stats.most_dependencies {
176        println!(
177            "Most dependencies: {node_id} (out-degree: {})",
178            stats.max_out_degree
179        );
180    }
181
182    if !stats.category_distribution.is_empty() {
183        println!("\nCategories:");
184        let mut cats: Vec<_> = stats.category_distribution.iter().collect();
185        cats.sort_by(|a, b| b.1.cmp(a.1));
186        for (cat, count) in cats {
187            println!("  {cat}: {count}");
188        }
189    }
190
191    if !stats.relationship_distribution.is_empty() {
192        println!("\nRelationships:");
193        let mut rels: Vec<_> = stats.relationship_distribution.iter().collect();
194        rels.sort_by(|a, b| b.1.cmp(a.1));
195        for (rel, count) in rels {
196            println!("  {rel}: {count}");
197        }
198    }
199
200    Ok(())
201}
202
203/// Query the graph.
204pub async fn handle_query<C: ConfigProvider>(config: &C, options: QueryOptions) -> Result<()> {
205    let path = graph_path(config)?;
206    let graph = load_graph_or_error(&path)?;
207
208    match options.query_type.as_str() {
209        "related" => query_related(&graph, &options.id).await,
210        "prerequisites" => query_prerequisites(&graph, &options.id).await,
211        "path" => {
212            let to = options
213                .to
214                .ok_or_else(|| Error::config("--to is required for path queries"))?;
215            query_path(&graph, &options.id, &to).await
216        }
217        other => Err(Error::config(format!("Unknown query type: {other}"))),
218    }
219}
220
221// ============================================================================
222// Query implementations
223// ============================================================================
224
225async fn query_related(graph: &GraphData, id: &str) -> Result<()> {
226    let result = neighborhood(graph, id, 1, None)?;
227
228    println!("Related to '{id}':");
229    if result.nodes.is_empty() {
230        println!("  (no related nodes)");
231    } else {
232        for node in &result.nodes {
233            println!("  - {} ({})", node.id, node.title);
234        }
235    }
236    println!("\n{} related node(s)", result.nodes.len());
237
238    Ok(())
239}
240
241async fn query_prerequisites(graph: &GraphData, id: &str) -> Result<()> {
242    let result = prerequisites_sorted(graph, id)?;
243
244    println!("Prerequisites for '{}' (learning order):", result.target.id);
245    if result.ordered.is_empty() {
246        println!("  (no prerequisites)");
247    } else {
248        for (i, node) in result.ordered.iter().enumerate() {
249            println!("  {}. {} ({})", i + 1, node.id, node.title);
250        }
251    }
252    if result.has_cycles {
253        println!("\n  WARNING: Prerequisite cycle detected — ordering is approximate.");
254    }
255
256    Ok(())
257}
258
259async fn query_path(graph: &GraphData, from: &str, to: &str) -> Result<()> {
260    let result = shortest_path(graph, from, to)?;
261
262    if !result.found {
263        println!("No path found from '{from}' to '{to}'.");
264        return Ok(());
265    }
266
267    println!("Path from '{from}' to '{to}':");
268    for (i, node) in result.path.iter().enumerate() {
269        if i > 0 {
270            if let Some(edge) = result.edges.get(i - 1) {
271                println!("    --[{}]--> ", edge.relationship.name());
272            }
273        }
274        println!("  {}. {} ({})", i + 1, node.id, node.title);
275    }
276    println!("\nTotal weight: {:.2}", result.total_weight);
277
278    Ok(())
279}
280
281// ============================================================================
282// Helpers
283// ============================================================================
284
285/// Load graph from the standard path, returning a helpful error message.
286fn load_graph_or_error(path: &PathBuf) -> Result<GraphData> {
287    if !path.exists() {
288        return Err(Error::file_not_found(path));
289    }
290    load_graph(path)
291}
292
293// ============================================================================
294// Tests
295// ============================================================================
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use fabryk_graph::{Edge, Node, Relationship};
301    use std::path::PathBuf;
302    use tempfile::tempdir;
303
304    #[derive(Clone)]
305    struct TestConfig {
306        base: PathBuf,
307    }
308
309    impl ConfigProvider for TestConfig {
310        fn project_name(&self) -> &str {
311            "test"
312        }
313
314        fn base_path(&self) -> Result<PathBuf> {
315            Ok(self.base.clone())
316        }
317
318        fn content_path(&self, content_type: &str) -> Result<PathBuf> {
319            Ok(self.base.join(content_type))
320        }
321    }
322
323    /// Create a test graph and save it to the standard path.
324    fn setup_graph(dir: &std::path::Path) -> PathBuf {
325        let graph_dir = dir.join("data").join("graphs");
326        std::fs::create_dir_all(&graph_dir).unwrap();
327        let graph_path = graph_dir.join("graph.json");
328
329        let mut graph = GraphData::new();
330        graph.add_node(Node::new("a", "Node A").with_category("basics"));
331        graph.add_node(Node::new("b", "Node B").with_category("basics"));
332        graph.add_node(Node::new("c", "Node C").with_category("advanced"));
333        graph
334            .add_edge(Edge::new("a", "b", Relationship::Prerequisite))
335            .unwrap();
336        graph
337            .add_edge(Edge::new("b", "c", Relationship::Prerequisite))
338            .unwrap();
339        graph
340            .add_edge(Edge::new("a", "c", Relationship::RelatesTo))
341            .unwrap();
342
343        save_graph(&graph, &graph_path, None).unwrap();
344        graph_path
345    }
346
347    // ------------------------------------------------------------------------
348    // validate handler
349    // ------------------------------------------------------------------------
350
351    #[tokio::test]
352    async fn test_handle_validate_valid_graph() {
353        let dir = tempdir().unwrap();
354        setup_graph(dir.path());
355
356        let config = TestConfig {
357            base: dir.path().to_path_buf(),
358        };
359
360        let result = handle_validate(&config).await;
361        assert!(result.is_ok());
362    }
363
364    #[tokio::test]
365    async fn test_handle_validate_missing_graph() {
366        let dir = tempdir().unwrap();
367        let config = TestConfig {
368            base: dir.path().to_path_buf(),
369        };
370
371        let result = handle_validate(&config).await;
372        assert!(result.is_err());
373    }
374
375    // ------------------------------------------------------------------------
376    // stats handler
377    // ------------------------------------------------------------------------
378
379    #[tokio::test]
380    async fn test_handle_stats() {
381        let dir = tempdir().unwrap();
382        setup_graph(dir.path());
383
384        let config = TestConfig {
385            base: dir.path().to_path_buf(),
386        };
387
388        let result = handle_stats(&config).await;
389        assert!(result.is_ok());
390    }
391
392    #[tokio::test]
393    async fn test_handle_stats_missing_graph() {
394        let dir = tempdir().unwrap();
395        let config = TestConfig {
396            base: dir.path().to_path_buf(),
397        };
398
399        let result = handle_stats(&config).await;
400        assert!(result.is_err());
401    }
402
403    // ------------------------------------------------------------------------
404    // query handler: related
405    // ------------------------------------------------------------------------
406
407    #[tokio::test]
408    async fn test_handle_query_related() {
409        let dir = tempdir().unwrap();
410        setup_graph(dir.path());
411
412        let config = TestConfig {
413            base: dir.path().to_path_buf(),
414        };
415
416        let options = QueryOptions {
417            id: "a".to_string(),
418            query_type: "related".to_string(),
419            to: None,
420        };
421
422        let result = handle_query(&config, options).await;
423        assert!(result.is_ok());
424    }
425
426    #[tokio::test]
427    async fn test_handle_query_related_unknown_node() {
428        let dir = tempdir().unwrap();
429        setup_graph(dir.path());
430
431        let config = TestConfig {
432            base: dir.path().to_path_buf(),
433        };
434
435        let options = QueryOptions {
436            id: "nonexistent".to_string(),
437            query_type: "related".to_string(),
438            to: None,
439        };
440
441        let result = handle_query(&config, options).await;
442        assert!(result.is_err());
443    }
444
445    // ------------------------------------------------------------------------
446    // query handler: prerequisites
447    // ------------------------------------------------------------------------
448
449    #[tokio::test]
450    async fn test_handle_query_prerequisites() {
451        let dir = tempdir().unwrap();
452        setup_graph(dir.path());
453
454        let config = TestConfig {
455            base: dir.path().to_path_buf(),
456        };
457
458        let options = QueryOptions {
459            id: "c".to_string(),
460            query_type: "prerequisites".to_string(),
461            to: None,
462        };
463
464        let result = handle_query(&config, options).await;
465        assert!(result.is_ok());
466    }
467
468    // ------------------------------------------------------------------------
469    // query handler: path
470    // ------------------------------------------------------------------------
471
472    #[tokio::test]
473    async fn test_handle_query_path() {
474        let dir = tempdir().unwrap();
475        setup_graph(dir.path());
476
477        let config = TestConfig {
478            base: dir.path().to_path_buf(),
479        };
480
481        let options = QueryOptions {
482            id: "a".to_string(),
483            query_type: "path".to_string(),
484            to: Some("c".to_string()),
485        };
486
487        let result = handle_query(&config, options).await;
488        assert!(result.is_ok());
489    }
490
491    #[tokio::test]
492    async fn test_handle_query_path_missing_to() {
493        let dir = tempdir().unwrap();
494        setup_graph(dir.path());
495
496        let config = TestConfig {
497            base: dir.path().to_path_buf(),
498        };
499
500        let options = QueryOptions {
501            id: "a".to_string(),
502            query_type: "path".to_string(),
503            to: None,
504        };
505
506        let result = handle_query(&config, options).await;
507        assert!(result.is_err());
508    }
509
510    // ------------------------------------------------------------------------
511    // query handler: unknown type
512    // ------------------------------------------------------------------------
513
514    #[tokio::test]
515    async fn test_handle_query_unknown_type() {
516        let dir = tempdir().unwrap();
517        setup_graph(dir.path());
518
519        let config = TestConfig {
520            base: dir.path().to_path_buf(),
521        };
522
523        let options = QueryOptions {
524            id: "a".to_string(),
525            query_type: "unknown".to_string(),
526            to: None,
527        };
528
529        let result = handle_query(&config, options).await;
530        assert!(result.is_err());
531    }
532
533    // ------------------------------------------------------------------------
534    // build handler (dry run)
535    // ------------------------------------------------------------------------
536
537    #[test]
538    fn test_build_options_default() {
539        let options = BuildOptions {
540            output: None,
541            dry_run: true,
542        };
543        assert!(options.dry_run);
544        assert!(options.output.is_none());
545    }
546
547    #[test]
548    fn test_build_options_with_output() {
549        let options = BuildOptions {
550            output: Some("/tmp/graph.json".to_string()),
551            dry_run: false,
552        };
553        assert!(!options.dry_run);
554        assert_eq!(options.output.unwrap(), "/tmp/graph.json");
555    }
556
557    // ------------------------------------------------------------------------
558    // helper: graph_path
559    // ------------------------------------------------------------------------
560
561    #[test]
562    fn test_graph_path() {
563        let config = TestConfig {
564            base: PathBuf::from("/project"),
565        };
566        let path = graph_path(&config).unwrap();
567        assert_eq!(path, PathBuf::from("/project/data/graphs/graph.json"));
568    }
569
570    // ------------------------------------------------------------------------
571    // helper: load_graph_or_error
572    // ------------------------------------------------------------------------
573
574    #[test]
575    fn test_load_graph_or_error_missing() {
576        let result = load_graph_or_error(&PathBuf::from("/nonexistent/graph.json"));
577        assert!(result.is_err());
578    }
579
580    #[test]
581    fn test_load_graph_or_error_success() {
582        let dir = tempdir().unwrap();
583        let path = dir.path().join("graph.json");
584
585        let mut graph = GraphData::new();
586        graph.add_node(Node::new("a", "A"));
587        save_graph(&graph, &path, None).unwrap();
588
589        let loaded = load_graph_or_error(&path).unwrap();
590        assert_eq!(loaded.node_count(), 1);
591    }
592}