1pub mod client;
34pub mod diff;
35pub mod prompts;
36
37use crate::schema::{Schema, SchemaError};
38
39#[derive(Debug)]
43pub enum GenerateError {
44 MissingApiKey,
46 Transport(String),
49 Schema(SchemaError),
52 EmptyResult,
57}
58
59impl std::fmt::Display for GenerateError {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 match self {
62 Self::MissingApiKey => f.write_str(
63 "ANTHROPIC_API_KEY is not set. Set it in your environment before running \
64 `rustio ai generate`.",
65 ),
66 Self::Transport(msg) => write!(f, "anthropic API transport error: {msg}"),
67 Self::Schema(err) => write!(f, "anthropic API returned invalid schema: {err}"),
68 Self::EmptyResult => f.write_str(
69 "Refusing to apply update: schema would become empty",
70 ),
71 }
72 }
73}
74
75impl std::error::Error for GenerateError {}
76
77impl From<SchemaError> for GenerateError {
78 fn from(e: SchemaError) -> Self {
79 GenerateError::Schema(e)
80 }
81}
82
83pub async fn generate(prompt: &str) -> Result<Schema, GenerateError> {
90 let api_key = api_key()?;
91 let body = client::request(&api_key, prompt)
92 .await
93 .map_err(|e| GenerateError::Transport(e.to_string()))?;
94 parse_response(&body)
95}
96
97pub async fn update(existing: &Schema, instruction: &str) -> Result<Schema, GenerateError> {
105 let api_key = api_key()?;
106 let existing_json = existing
107 .to_pretty_json()
108 .map_err(|e| GenerateError::Transport(format!("serialise existing schema: {e}")))?;
109 let body = client::request_update(&api_key, &existing_json, instruction)
110 .await
111 .map_err(|e| GenerateError::Transport(e.to_string()))?;
112 let updated = parse_response(&body)?;
113 check_not_empty(existing, &updated)?;
114 Ok(updated)
115}
116
117pub(crate) fn check_not_empty(old: &Schema, new: &Schema) -> Result<(), GenerateError> {
127 if new.models.is_empty() && !old.models.is_empty() {
128 return Err(GenerateError::EmptyResult);
129 }
130 Ok(())
131}
132
133fn api_key() -> Result<String, GenerateError> {
136 std::env::var("ANTHROPIC_API_KEY")
137 .ok()
138 .filter(|s| !s.trim().is_empty())
139 .ok_or(GenerateError::MissingApiKey)
140}
141
142#[derive(Debug, Clone, PartialEq)]
147pub struct AnalyzeReport {
148 pub issues: Vec<String>,
149 pub suggestions: Vec<String>,
150 pub score: f32,
151}
152
153#[derive(Debug)]
157pub enum AnalyzeError {
158 MissingApiKey,
159 Transport(String),
160 Encode(String),
164}
165
166impl std::fmt::Display for AnalyzeError {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 match self {
169 Self::MissingApiKey => f.write_str(
170 "ANTHROPIC_API_KEY is not set. Set it in your environment before running \
171 `rustio ai analyze`.",
172 ),
173 Self::Transport(msg) => write!(f, "anthropic API transport error: {msg}"),
174 Self::Encode(msg) => write!(f, "could not serialise schema for analyze: {msg}"),
175 }
176 }
177}
178
179impl std::error::Error for AnalyzeError {}
180
181pub async fn analyze(schema: &Schema) -> Result<AnalyzeReport, AnalyzeError> {
186 let api_key = std::env::var("ANTHROPIC_API_KEY")
187 .ok()
188 .filter(|s| !s.trim().is_empty())
189 .ok_or(AnalyzeError::MissingApiKey)?;
190 let existing_json = schema
191 .to_pretty_json()
192 .map_err(|e| AnalyzeError::Encode(e.to_string()))?;
193 let body = client::request_analyze(&api_key, &existing_json)
194 .await
195 .map_err(AnalyzeError::Transport)?;
196 Ok(parse_analyze_response(&body))
197}
198
199pub fn parse_analyze_response(body: &str) -> AnalyzeReport {
214 let body = body.trim();
215 if body.is_empty() {
216 return AnalyzeReport {
217 issues: Vec::new(),
218 suggestions: Vec::new(),
219 score: 0.0,
220 };
221 }
222
223 let lower = body.to_lowercase();
224 let has_section_header = lower.contains("issues:")
225 || lower.contains("suggestions:")
226 || lower.contains("score:");
227
228 if !has_section_header {
229 let suggestions = collect_bullets(body);
233 return AnalyzeReport {
234 issues: Vec::new(),
235 suggestions,
236 score: 0.0,
237 };
238 }
239
240 let mut section = Section::None;
241 let mut issues: Vec<String> = Vec::new();
242 let mut suggestions: Vec<String> = Vec::new();
243 let mut score: f32 = 0.0;
244
245 for raw_line in body.lines() {
246 let line = raw_line.trim();
247 let lower = line.to_lowercase();
248 if lower.starts_with("issues:") {
253 section = Section::Issues;
254 continue;
255 }
256 if lower.starts_with("suggestions:") {
257 section = Section::Suggestions;
258 continue;
259 }
260 if lower.starts_with("score:") {
261 section = Section::Score;
262 score = parse_score(line["score:".len()..].trim()).unwrap_or(0.0);
263 continue;
264 }
265
266 if line.is_empty() || line.eq_ignore_ascii_case("(none)") {
268 continue;
269 }
270
271 let item = line
273 .strip_prefix("- ")
274 .or_else(|| line.strip_prefix("* "))
275 .unwrap_or(line)
276 .to_string();
277
278 match section {
279 Section::Issues => issues.push(item),
280 Section::Suggestions => suggestions.push(item),
281 Section::Score | Section::None => {}
284 }
285 }
286
287 AnalyzeReport { issues, suggestions, score }
288}
289
290enum Section {
292 None,
293 Issues,
294 Suggestions,
295 Score,
296}
297
298fn parse_score(s: &str) -> Option<f32> {
301 let s = s.trim();
302 let end = s
304 .char_indices()
305 .find(|(_, c)| !(c.is_ascii_digit() || *c == '.' || *c == '-'))
306 .map(|(i, _)| i)
307 .unwrap_or(s.len());
308 s[..end].parse::<f32>().ok()
309}
310
311fn collect_bullets(body: &str) -> Vec<String> {
315 body.lines()
316 .map(str::trim)
317 .filter(|l| !l.is_empty())
318 .map(|l| {
319 l.strip_prefix("- ")
320 .or_else(|| l.strip_prefix("* "))
321 .unwrap_or(l)
322 .to_string()
323 })
324 .collect()
325}
326
327#[derive(Debug, Clone, PartialEq)]
332pub struct ExplainReport {
333 pub why: Vec<String>,
334 pub impact: Vec<String>,
335}
336
337#[derive(Debug)]
341pub enum ExplainError {
342 MissingApiKey,
343 Transport(String),
344 Encode(String),
346}
347
348impl std::fmt::Display for ExplainError {
349 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
350 match self {
351 Self::MissingApiKey => f.write_str(
352 "ANTHROPIC_API_KEY is not set. Set it in your environment before requesting \
353 an explanation.",
354 ),
355 Self::Transport(msg) => write!(f, "anthropic API transport error: {msg}"),
356 Self::Encode(msg) => write!(f, "could not serialise schema for explain: {msg}"),
357 }
358 }
359}
360
361impl std::error::Error for ExplainError {}
362
363pub async fn explain_diff(
372 old: &Schema,
373 new: &Schema,
374 api_key: &str,
375) -> Result<ExplainReport, ExplainError> {
376 if api_key.trim().is_empty() {
377 return Err(ExplainError::MissingApiKey);
378 }
379 let old_json = old
380 .to_pretty_json()
381 .map_err(|e| ExplainError::Encode(e.to_string()))?;
382 let new_json = new
383 .to_pretty_json()
384 .map_err(|e| ExplainError::Encode(e.to_string()))?;
385 let body = client::request_explain(api_key, &old_json, &new_json)
386 .await
387 .map_err(ExplainError::Transport)?;
388 Ok(parse_explain_response(&body))
389}
390
391pub fn parse_explain_response(body: &str) -> ExplainReport {
401 let body = body.trim();
402 if body.is_empty() {
403 return ExplainReport { why: Vec::new(), impact: Vec::new() };
404 }
405
406 let lower = body.to_lowercase();
407 let has_section_header = lower.contains("why:") || lower.contains("impact:");
408 if !has_section_header {
409 return ExplainReport { why: collect_bullets(body), impact: Vec::new() };
410 }
411
412 let mut section = ExplainSection::None;
413 let mut why: Vec<String> = Vec::new();
414 let mut impact: Vec<String> = Vec::new();
415
416 for raw_line in body.lines() {
417 let line = raw_line.trim();
418 let lower = line.to_lowercase();
419
420 if lower.starts_with("why:") {
421 section = ExplainSection::Why;
422 continue;
423 }
424 if lower.starts_with("impact:") {
425 section = ExplainSection::Impact;
426 continue;
427 }
428
429 if line.is_empty() || line.eq_ignore_ascii_case("(none)") {
430 continue;
431 }
432
433 let bullet = line
440 .strip_prefix("- ")
441 .or_else(|| line.strip_prefix("* "));
442
443 match (§ion, bullet) {
444 (ExplainSection::Why, Some(item)) => why.push(item.to_string()),
445 (ExplainSection::Impact, Some(item)) => impact.push(item.to_string()),
446 (ExplainSection::Why | ExplainSection::Impact, None) => {
447 section = ExplainSection::None;
448 }
449 (ExplainSection::None, _) => {}
450 }
451 }
452
453 ExplainReport { why, impact }
454}
455
456enum ExplainSection {
458 None,
459 Why,
460 Impact,
461}
462
463pub fn parse_response(body: &str) -> Result<Schema, GenerateError> {
472 let json = extract_schema_json(body);
473 Ok(Schema::parse(json)?)
474}
475
476pub(crate) fn extract_schema_json(body: &str) -> &str {
481 let trimmed = body.trim();
482 let stripped = trimmed
483 .strip_prefix("```json")
484 .or_else(|| trimmed.strip_prefix("```"))
485 .unwrap_or(trimmed);
486 let stripped = stripped.trim_start_matches('\n');
487 stripped.strip_suffix("```").map_or(stripped, str::trim_end)
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 #[test]
498 fn extract_schema_json_strips_fence() {
499 let fenced = "```json\n{\"version\":2}\n```";
500 assert_eq!(extract_schema_json(fenced), "{\"version\":2}");
501
502 let fenced_no_lang = "```\n{\"version\":2}\n```";
503 assert_eq!(extract_schema_json(fenced_no_lang), "{\"version\":2}");
504
505 let plain = " {\"version\":2} ";
507 assert_eq!(extract_schema_json(plain), "{\"version\":2}");
508 }
509
510 #[test]
514 fn parse_response_accepts_valid_schema() {
515 let body = r#"{
516 "version": 2,
517 "rustio_version": "1.0.0",
518 "models": [
519 {
520 "name": "Post",
521 "table": "posts",
522 "admin_name": "posts",
523 "display_name": "Posts",
524 "singular_name": "Post",
525 "fields": [
526 { "name": "id", "type": "i64", "nullable": false, "editable": true },
527 { "name": "title", "type": "String", "nullable": false, "editable": true }
528 ],
529 "relations": []
530 }
531 ]
532 }"#;
533
534 let schema = parse_response(body).expect("valid response parses");
535 assert_eq!(schema.models.len(), 1);
536 assert_eq!(schema.models[0].name, "Post");
537 }
538
539 #[test]
545 fn update_adds_new_model() {
546 let response = r#"{
549 "version": 2,
550 "rustio_version": "1.0.0",
551 "models": [
552 {
553 "name": "Post",
554 "table": "posts",
555 "admin_name": "posts",
556 "display_name": "Posts",
557 "singular_name": "Post",
558 "fields": [
559 { "name": "id", "type": "i64", "nullable": false, "editable": true },
560 { "name": "title", "type": "String", "nullable": false, "editable": true }
561 ],
562 "relations": []
563 },
564 {
565 "name": "Tag",
566 "table": "tags",
567 "admin_name": "tags",
568 "display_name": "Tags",
569 "singular_name": "Tag",
570 "fields": [
571 { "name": "id", "type": "i64", "nullable": false, "editable": true },
572 { "name": "label", "type": "String", "nullable": false, "editable": true }
573 ],
574 "relations": []
575 }
576 ]
577 }"#;
578 let updated = parse_response(response).expect("valid update parses");
579 assert!(updated.models.iter().any(|m| m.name == "Tag"));
580 assert!(updated.models.iter().any(|m| m.name == "Post"));
581 }
582
583 #[test]
589 fn update_preserves_existing_fields() {
590 let original = r#"{
591 "version": 2,
592 "rustio_version": "1.0.0",
593 "models": [
594 {
595 "name": "Post",
596 "table": "posts",
597 "admin_name": "posts",
598 "display_name": "Posts",
599 "singular_name": "Post",
600 "fields": [
601 { "name": "id", "type": "i64", "nullable": false, "editable": true },
602 { "name": "title", "type": "String", "nullable": false, "editable": true },
603 { "name": "body", "type": "String", "nullable": false, "editable": true }
604 ],
605 "relations": []
606 }
607 ]
608 }"#;
609 let response = r#"{
610 "version": 2,
611 "rustio_version": "1.0.0",
612 "models": [
613 {
614 "name": "Post",
615 "table": "posts",
616 "admin_name": "posts",
617 "display_name": "Posts",
618 "singular_name": "Post",
619 "fields": [
620 { "name": "id", "type": "i64", "nullable": false, "editable": true },
621 { "name": "title", "type": "String", "nullable": false, "editable": true },
622 { "name": "body", "type": "String", "nullable": false, "editable": true },
623 { "name": "status", "type": "String", "nullable": false, "editable": true }
624 ],
625 "relations": []
626 }
627 ]
628 }"#;
629
630 let old = parse_response(original).expect("original parses");
631 let new = parse_response(response).expect("response parses");
632 let changes = diff::diff(&old, &new);
633
634 for surviving in ["id", "title", "body"] {
636 assert!(
637 !changes.iter().any(|c| matches!(c,
638 diff::Change::FieldRemoved { field, .. } if field == surviving
639 )),
640 "preserved field {surviving} surfaced as removed: {changes:?}"
641 );
642 }
643 let adds: Vec<_> = changes
645 .iter()
646 .filter(|c| matches!(c, diff::Change::FieldAdded { .. }))
647 .collect();
648 assert_eq!(adds.len(), 1);
649 }
650
651 #[test]
656 fn update_invalid_json_rejected() {
657 let bad = r#"{
659 "version": 2,
660 "rustio_version": "1.0.0",
661 "models": [],
662 }"#;
663 let err = parse_response(bad).expect_err("malformed JSON must be rejected");
664 assert!(matches!(err, GenerateError::Schema(_)));
665 }
666
667 #[test]
675 fn no_live_api_calls() {
676 let _ = std::env::var("ANTHROPIC_API_KEY"); let dummy = r#"{
680 "version": 2, "rustio_version": "1.0.0",
681 "models": [
682 { "name": "Post", "table": "posts", "admin_name": "posts",
683 "display_name": "Posts", "singular_name": "Post",
684 "fields": [
685 { "name": "id", "type": "i64", "nullable": false, "editable": true }
686 ],
687 "relations": []
688 }
689 ]
690 }"#;
691 let parsed = parse_response(dummy).expect("offline parse path works");
692 let _ = diff::diff(&parsed, &parsed); }
694
695 #[test]
699 fn analyze_detects_missing_relation_model() {
700 let body = "ISSUES:\n\
701- Post.author_id has relation but User model missing\n\
702\n\
703SUGGESTIONS:\n\
704- Add created_at timestamp to all models\n\
705\n\
706SCORE: 6.0\n";
707 let report = parse_analyze_response(body);
708 assert_eq!(report.issues.len(), 1);
709 assert!(report.issues[0].contains("author_id"));
710 assert!(report.issues[0].contains("User"));
711 assert_eq!(report.suggestions.len(), 1);
712 assert!((report.score - 6.0).abs() < f32::EPSILON);
713 }
714
715 #[test]
718 fn analyze_suggests_best_practices() {
719 let body = "ISSUES:\n\
720(none)\n\
721\n\
722SUGGESTIONS:\n\
723- Add created_at and updated_at to every model\n\
724- Index Comment.post_id\n\
725- Consider an enum for Post.status\n\
726\n\
727SCORE: 8.5\n";
728 let report = parse_analyze_response(body);
729 assert!(report.issues.is_empty(), "issues bucket should be empty");
730 assert_eq!(report.suggestions.len(), 3);
731 assert!(report.suggestions.iter().any(|s| s.contains("created_at")));
732 assert!(report.suggestions.iter().any(|s| s.contains("Index")));
733 assert!(report.suggestions.iter().any(|s| s.contains("enum")));
734 assert!((report.score - 8.5).abs() < f32::EPSILON);
735 }
736
737 #[test]
741 fn analyze_parsing_valid_output() {
742 let body = "ISSUES:\n\
743- Post.author_id has relation but User model missing\n\
744- Comment.post_id not indexed\n\
745\n\
746SUGGESTIONS:\n\
747- Add created_at timestamp to all models\n\
748- Add index on foreign keys\n\
749\n\
750SCORE: 7.5 / 10\n";
751 let report = parse_analyze_response(body);
752 assert_eq!(report.issues.len(), 2);
753 assert_eq!(report.suggestions.len(), 2);
754 assert!((report.score - 7.5).abs() < f32::EPSILON);
755 }
756
757 #[test]
762 fn analyze_handles_unstructured_output() {
763 let body = "Looks fine overall. Maybe think about adding indexes \n\
764on the foreign keys, and consider an enum for Post.status.\n\
765- Add created_at on every model.";
766 let report = parse_analyze_response(body);
767 assert!(report.issues.is_empty(), "unstructured input → issues must be empty");
768 assert!(
769 !report.suggestions.is_empty(),
770 "unstructured input → fallback should populate suggestions"
771 );
772 assert_eq!(report.suggestions.len(), 3);
775 assert!(report.suggestions[2].starts_with("Add created_at"));
776 assert_eq!(report.score, 0.0, "no SCORE: header → default 0.0");
777 }
778
779 #[test]
787 fn analyze_no_live_api_calls() {
788 let _ = std::env::var("ANTHROPIC_API_KEY"); let report = parse_analyze_response(
793 "ISSUES:\n(none)\n\nSUGGESTIONS:\n(none)\n\nSCORE: 9\n",
794 );
795 assert_eq!(report.issues.len(), 0);
796 assert_eq!(report.suggestions.len(), 0);
797 assert_eq!(report.score, 9.0);
798 }
799
800 #[test]
807 fn explain_parses_valid_response() {
808 let body = "WHY:\n\
809- Tags allow flexible categorization of posts\n\
810- Decoupling from rigid categories\n\
811\n\
812IMPACT:\n\
813- Adds new table (Tag)\n\
814- Introduces many-to-many relationship\n";
815 let report = parse_explain_response(body);
816 assert_eq!(report.why.len(), 2);
817 assert!(report.why[0].starts_with("Tags allow"));
818 assert!(report.why[1].starts_with("Decoupling"));
819 assert_eq!(report.impact.len(), 2);
820 assert!(report.impact[0].starts_with("Adds new table"));
821 assert!(report.impact[1].starts_with("Introduces"));
822 }
823
824 #[test]
828 fn explain_handles_missing_sections() {
829 let body = "IMPACT:\n- Adds Tag table\n";
831 let report = parse_explain_response(body);
832 assert!(report.why.is_empty(), "WHY missing → empty bucket");
833 assert_eq!(report.impact.len(), 1);
834
835 let body = "WHY:\n- Tags help categorize\n";
837 let report = parse_explain_response(body);
838 assert_eq!(report.why.len(), 1);
839 assert!(report.impact.is_empty(), "IMPACT missing → empty bucket");
840
841 let body = "WHY:\n(none)\n\nIMPACT:\n(none)\n";
843 let report = parse_explain_response(body);
844 assert!(report.why.is_empty());
845 assert!(report.impact.is_empty());
846 }
847
848 #[test]
853 fn explain_ignores_extra_text() {
854 let body = "WHY:\n\
855- Improves categorization\n\
856\n\
857IMPACT:\n\
858- New table\n\
859\n\
860This concludes the explanation. Hope it helps!\n";
861 let report = parse_explain_response(body);
862 assert_eq!(report.why.len(), 1);
863 assert_eq!(report.impact.len(), 1);
864 assert!(report.impact[0].starts_with("New table"));
865 assert!(
867 !report.impact.iter().any(|l| l.contains("This concludes")),
868 "trailing commentary leaked into impact: {:?}",
869 report.impact
870 );
871 assert!(
872 !report.why.iter().any(|l| l.contains("This concludes")),
873 "trailing commentary leaked into why: {:?}",
874 report.why
875 );
876 }
877
878 #[test]
882 fn explain_fallback_treats_unstructured_as_why() {
883 let body = "Tags help categorize posts.\n\
884- New table is added.\n\
885- Many-to-many relationship is introduced.";
886 let report = parse_explain_response(body);
887 assert!(
888 report.impact.is_empty(),
889 "no headers → impact must be empty"
890 );
891 assert_eq!(report.why.len(), 3);
892 assert_eq!(report.why[0], "Tags help categorize posts.");
893 assert_eq!(report.why[1], "New table is added.");
894 }
895
896 #[test]
903 fn update_refuses_empty_result() {
904 let one_model = crate::schema::Schema {
905 version: crate::schema::SCHEMA_VERSION,
906 rustio_version: "1.0.0".into(),
907 models: vec![crate::schema::SchemaModel {
908 name: "Post".into(),
909 table: "posts".into(),
910 admin_name: "posts".into(),
911 display_name: "Posts".into(),
912 singular_name: "Post".into(),
913 fields: vec![],
914 relations: vec![],
915 core: false,
916 }],
917 };
918 let empty = crate::schema::Schema {
919 version: crate::schema::SCHEMA_VERSION,
920 rustio_version: "1.0.0".into(),
921 models: vec![],
922 };
923
924 let err = check_not_empty(&one_model, &empty)
926 .expect_err("non-empty → empty must reject");
927 assert!(
928 matches!(err, GenerateError::EmptyResult),
929 "expected EmptyResult, got {err:?}"
930 );
931 assert_eq!(
932 err.to_string(),
933 "Refusing to apply update: schema would become empty"
934 );
935
936 check_not_empty(&one_model, &one_model)
938 .expect("non-empty preservation must pass");
939 check_not_empty(&empty, &empty).expect("empty no-op must pass");
940 check_not_empty(&empty, &one_model)
941 .expect("first-time fill must pass");
942 }
943
944 #[test]
948 fn parse_response_rejects_invalid_schema() {
949 let body = r#"{
950 "version": 2,
951 "rustio_version": "1.0.0",
952 "models": [
953 {
954 "name": "Post",
955 "table": "posts",
956 "admin_name": "posts",
957 "display_name": "Posts",
958 "singular_name": "Post",
959 "fields": [
960 { "name": "id", "type": "i64", "nullable": false, "editable": true },
961 { "name": "title", "type": "FooBar", "nullable": false, "editable": true }
962 ],
963 "relations": []
964 }
965 ]
966 }"#;
967
968 let err = parse_response(body).expect_err("invalid type must reject");
969 match err {
970 GenerateError::Schema(SchemaError::InvalidType { ref ty, .. }) => {
971 assert_eq!(ty, "FooBar");
972 }
973 other => panic!("expected Schema(InvalidType), got {other:?}"),
974 }
975 }
976}