1use 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#[derive(Debug, Clone)]
20pub struct BuildOptions {
21 pub output: Option<String>,
23 pub dry_run: bool,
25}
26
27#[derive(Debug, Clone)]
29pub struct QueryOptions {
30 pub id: String,
32 pub query_type: String,
34 pub to: Option<String>,
36}
37
38fn 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
48pub 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 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
105pub 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
151pub 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
203pub 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
221async 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
281fn 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#[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}