spreadsheet_kit/cli/
errors.rs1use crate::cli::OutputFormat;
2use crate::model::{FORMULA_PARSE_FAILED, FORMULA_PARSE_FAILED_PREFIX};
3use anyhow::{Result, bail};
4use serde::Serialize;
5
6pub fn ensure_output_supported(format: OutputFormat) -> Result<()> {
7 match format {
8 OutputFormat::Json => Ok(()),
9 OutputFormat::Csv => {
10 bail!("csv output is not implemented yet for this CLI; use --output-format json")
11 }
12 }
13}
14
15#[derive(Debug, Serialize)]
16pub struct ErrorEnvelope {
17 pub code: String,
18 pub message: String,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub did_you_mean: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub try_this: Option<String>,
23}
24
25pub fn envelope_for(error: &anyhow::Error) -> ErrorEnvelope {
26 let message = error.to_string();
27
28 if let Some((requested, suggested)) = parse_sheet_suggestion(&message) {
29 return ErrorEnvelope {
30 code: "SHEET_NOT_FOUND".to_string(),
31 message: format!("sheet '{}' was not found", requested),
32 did_you_mean: Some(suggested),
33 try_this: Some("run `asp read sheets <file>` to inspect valid names".to_string()),
34 };
35 }
36
37 if let Some(requested) = parse_sheet_not_found(&message) {
38 return ErrorEnvelope {
39 code: "SHEET_NOT_FOUND".to_string(),
40 message: format!("sheet '{}' was not found", requested),
41 did_you_mean: None,
42 try_this: Some("run `asp read sheets <file>` to inspect valid names".to_string()),
43 };
44 }
45
46 if let Some(detail) = message.strip_prefix("invalid argument: ") {
47 let try_this = if detail.contains("session payload kind") {
48 "run `asp example session op transform.write_matrix` or `asp schema session op transform.write_matrix` to inspect supported kinds and canonical payloads".to_string()
49 } else {
50 "run the command with --help to inspect valid arguments".to_string()
51 };
52 return ErrorEnvelope {
53 code: "INVALID_ARGUMENT".to_string(),
54 message: detail.to_string(),
55 did_you_mean: None,
56 try_this: Some(try_this),
57 };
58 }
59
60 if let Some(detail) = message.strip_prefix("invalid ops payload: ") {
61 return ErrorEnvelope {
62 code: "INVALID_OPS_PAYLOAD".to_string(),
63 message: detail.to_string(),
64 did_you_mean: None,
65 try_this: Some("pass --ops @<path-to-json> with payload {\"ops\":[...]}".to_string()),
66 };
67 }
68
69 if message.starts_with("invalid session ops payload") {
70 return ErrorEnvelope {
71 code: "INVALID_OPS_PAYLOAD".to_string(),
72 message,
73 did_you_mean: None,
74 try_this: Some(
75 "run `asp example session op transform.write_matrix` or `asp schema session op transform.write_matrix` to inspect the canonical payload contract".to_string(),
76 ),
77 };
78 }
79
80 if let Some(detail) = message.strip_prefix("output exists: ") {
81 return ErrorEnvelope {
82 code: "OUTPUT_EXISTS".to_string(),
83 message: detail.to_string(),
84 did_you_mean: None,
85 try_this: Some("choose a new --output path or re-run with --force".to_string()),
86 };
87 }
88
89 if let Some(detail) = message.strip_prefix("unsafe clone template: ") {
90 return ErrorEnvelope {
91 code: "UNSAFE_CLONE_TEMPLATE".to_string(),
92 message: detail.to_string(),
93 did_you_mean: None,
94 try_this: Some(
95 "re-run with --merge-policy safe or choose a different template row".to_string(),
96 ),
97 };
98 }
99
100 if let Some(detail) = message.strip_prefix("write failed: ") {
101 return ErrorEnvelope {
102 code: "WRITE_FAILED".to_string(),
103 message: detail.to_string(),
104 did_you_mean: None,
105 try_this: Some("check destination permissions and available disk space".to_string()),
106 };
107 }
108
109 if message.contains("does not exist") {
110 return ErrorEnvelope {
111 code: "FILE_NOT_FOUND".to_string(),
112 message,
113 did_you_mean: None,
114 try_this: Some("check the workbook path and permissions".to_string()),
115 };
116 }
117
118 if message.contains("at least one range") {
119 return ErrorEnvelope {
120 code: "INVALID_ARGUMENT".to_string(),
121 message,
122 did_you_mean: None,
123 try_this: Some("pass one or more A1 ranges, for example: `A1:C10`".to_string()),
124 };
125 }
126
127 if message.contains("at least one edit") {
128 return ErrorEnvelope {
129 code: "INVALID_ARGUMENT".to_string(),
130 message,
131 did_you_mean: None,
132 try_this: Some("add one or more edits like `A1=42` or `B2==SUM(A1:A1)`".to_string()),
133 };
134 }
135
136 if message.contains("invalid shorthand edit") {
137 return ErrorEnvelope {
138 code: "INVALID_EDIT_SYNTAX".to_string(),
139 message,
140 did_you_mean: None,
141 try_this: Some(
142 "use `<cell>=<value>` for values or `<cell>==<formula>` for formulas".to_string(),
143 ),
144 };
145 }
146
147 if message.contains("csv output is not implemented") {
148 return ErrorEnvelope {
149 code: "OUTPUT_FORMAT_UNSUPPORTED".to_string(),
150 message,
151 did_you_mean: Some("json".to_string()),
152 try_this: Some("re-run with `--output-format json`".to_string()),
153 };
154 }
155
156 if message.starts_with(FORMULA_PARSE_FAILED_PREFIX) {
157 return ErrorEnvelope {
158 code: FORMULA_PARSE_FAILED.to_string(),
159 message,
160 did_you_mean: None,
161 try_this: Some(
162 "re-run with --formula-parse-policy warn to collect diagnostics instead of aborting"
163 .to_string(),
164 ),
165 };
166 }
167
168 ErrorEnvelope {
169 code: "COMMAND_FAILED".to_string(),
170 message,
171 did_you_mean: None,
172 try_this: None,
173 }
174}
175
176fn parse_sheet_suggestion(message: &str) -> Option<(String, String)> {
177 let prefix = "sheet '";
178 let not_found = "' not found; did you mean '";
179 let suffix = "' ?";
180
181 let start = message.find(prefix)? + prefix.len();
182 let rest = &message[start..];
183 let mid = rest.find(not_found)?;
184 let requested = &rest[..mid];
185 let suggestion_start = start + mid + not_found.len();
186 let suggestion_rest = &message[suggestion_start..];
187 let suggestion_end = suggestion_rest.find(suffix)?;
188 let suggested = &suggestion_rest[..suggestion_end];
189 Some((requested.to_string(), suggested.to_string()))
190}
191
192fn parse_sheet_not_found(message: &str) -> Option<String> {
193 let rest = message.strip_prefix("sheet ")?;
194 if rest.contains(" not found; did you mean ") {
195 return None;
196 }
197 if let Some(stripped) = rest.strip_prefix('\'')
198 && let Some(requested) = stripped.strip_suffix("' not found")
199 {
200 return Some(requested.to_string());
201 }
202 rest.strip_suffix(" not found").map(str::to_string)
203}