1use crate::error::RuntimeError;
2use crate::storage;
3use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
4use crate::value::Value;
5use atman_dsl::ast::{Expr, FlowDecl, Literal, Stmt, TypeExpr};
6use std::path::Path;
7
8const DEFAULT_SEARCH_LIMIT: usize = 10;
9const MAX_SEARCH_LIMIT: usize = 50;
10
11pub struct FlowList;
12pub struct FlowSearch;
13pub struct FlowDescribe;
14
15#[derive(Clone)]
16struct FlowParameter {
17 name: String,
18 ty: String,
19 default: Option<Value>,
20}
21
22#[derive(Clone)]
23struct FlowEntry {
24 name: String,
25 reference: String,
26 version: String,
27 summary: String,
28 params: Vec<FlowParameter>,
29}
30
31struct FlowFile {
32 name: String,
33 description: String,
34 flows: Vec<FlowEntry>,
35}
36
37struct FlowCatalog {
38 fingerprint: String,
39 files: Vec<FlowFile>,
40}
41
42impl FlowCatalog {
43 fn load() -> Result<Self, RuntimeError> {
44 let config_dir = storage::config_dir()
45 .map_err(|error| RuntimeError::ToolFailed(format!("flow catalog: {error}")))?;
46 Self::load_from(&config_dir.join("commands"))
47 }
48
49 fn load_from(commands_dir: &Path) -> Result<Self, RuntimeError> {
50 let mut files = Vec::new();
51 if commands_dir.is_dir() {
52 let read = std::fs::read_dir(commands_dir).map_err(|error| {
53 RuntimeError::ToolFailed(format!(
54 "flow catalog: read {}: {error}",
55 commands_dir.display()
56 ))
57 })?;
58 for entry in read.flatten() {
59 let path = entry.path();
60 if path.extension().and_then(|extension| extension.to_str()) != Some("at") {
61 continue;
62 }
63 if let Ok(file) = scan_flow_file(&path) {
64 files.push(file);
65 }
66 }
67 }
68 files.sort_by(|left, right| left.name.cmp(&right.name));
69 let mut hasher = blake3::Hasher::new();
70 for file in &files {
71 for flow in &file.flows {
72 hasher.update(flow.reference.as_bytes());
73 hasher.update(&[0]);
74 hasher.update(flow.version.as_bytes());
75 hasher.update(&[0]);
76 }
77 }
78 Ok(Self {
79 fingerprint: format!("blake3:{}", hasher.finalize().to_hex()),
80 files,
81 })
82 }
83
84 fn searchable_entries(&self) -> Vec<&FlowEntry> {
85 let mut entries = self
86 .files
87 .iter()
88 .flat_map(|file| file.flows.iter())
89 .filter(|flow| flow.name != "describe")
90 .collect::<Vec<_>>();
91 entries.sort_by(|left, right| left.reference.cmp(&right.reference));
92 entries
93 }
94
95 fn find(&self, flow_ref: &str) -> Option<&FlowEntry> {
96 if flow_ref.contains('@') {
97 let normalized = normalize_flow_ref(flow_ref)?;
98 return self
99 .files
100 .iter()
101 .flat_map(|file| file.flows.iter())
102 .find(|flow| flow.reference == normalized);
103 }
104 let file_name = normalize_flow_file(flow_ref)?;
105 self.files
106 .iter()
107 .find(|file| file.name == file_name)?
108 .flows
109 .iter()
110 .find(|flow| flow.name != "describe")
111 }
112
113 fn legacy_value(&self) -> Value {
114 Value::List(
115 self.files
116 .iter()
117 .map(|file| {
118 Value::Struct(vec![
119 ("file".into(), Value::Str(file.name.clone())),
120 ("description".into(), Value::Str(file.description.clone())),
121 (
122 "flows".into(),
123 Value::List(file.flows.iter().map(legacy_flow_value).collect()),
124 ),
125 ])
126 })
127 .collect(),
128 )
129 }
130}
131
132impl Tool for FlowList {
133 fn name(&self) -> &str {
134 "flow.list"
135 }
136
137 fn tier(&self) -> Tier {
138 Tier::Zero
139 }
140
141 fn description(&self) -> Option<&str> {
142 Some(
143 "Return the complete flow catalog for compatibility. The result is unbounded; use \
144 flow.search followed by flow.describe for model-driven discovery.",
145 )
146 }
147
148 fn input_schema(&self) -> serde_json::Value {
149 serde_json::json!({"type": "object", "properties": {}})
150 }
151
152 fn call<'a>(&'a self, _args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
153 Box::pin(async move { Ok(FlowCatalog::load()?.legacy_value()) })
154 }
155}
156
157impl Tool for FlowSearch {
158 fn name(&self) -> &str {
159 "flow.search"
160 }
161
162 fn tier(&self) -> Tier {
163 Tier::Zero
164 }
165
166 fn description(&self) -> Option<&str> {
167 Some(
168 "Search installed DSL flows by ranked keywords without loading the complete catalog into context. \
169 Results contain an exact flow ref, a source fingerprint, and a short summary. \
170 Use flow.describe on one result before flow.spawn when its parameters are unknown.",
171 )
172 }
173
174 fn input_schema(&self) -> serde_json::Value {
175 serde_json::json!({
176 "type": "object",
177 "properties": {
178 "query": {"type": "string", "description": "Case-insensitive keywords ranked across flow name, ref, and summary. Empty string lists the first page."},
179 "limit": {"type": "integer", "minimum": 1, "maximum": MAX_SEARCH_LIMIT, "default": DEFAULT_SEARCH_LIMIT},
180 "cursor": {"type": "string", "minLength": 1, "description": "Pagination only. Omit this field for the first page. For a later page, pass the non-empty next_cursor returned by the immediately preceding flow.search call verbatim; never send an empty or invented value."}
181 },
182 "required": ["query"],
183 "additionalProperties": false
184 })
185 }
186
187 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
188 Box::pin(async move {
189 let query = string_arg(&args, "query", "flow.search")?;
190 let limit = limit_arg(&args, "flow.search")?;
191 let cursor = optional_string_arg(&args, "cursor", "flow.search")?;
192 search_catalog(&FlowCatalog::load()?, query, limit, cursor.as_deref())
193 })
194 }
195}
196
197impl Tool for FlowDescribe {
198 fn name(&self) -> &str {
199 "flow.describe"
200 }
201
202 fn tier(&self) -> Tier {
203 Tier::Zero
204 }
205
206 fn description(&self) -> Option<&str> {
207 Some(
208 "Describe one installed DSL flow. Returns its exact ref, current source fingerprint, \
209 summary, and parameter contract. Accepts an exact ref or installed flow-file shorthand. \
210 An optional version rejects stale search results.",
211 )
212 }
213
214 fn input_schema(&self) -> serde_json::Value {
215 serde_json::json!({
216 "type": "object",
217 "properties": {
218 "ref": {"type": "string", "description": "Exact flow ref returned by flow.search, or installed flow file such as subagent.at."},
219 "version": {"type": "string", "minLength": 1, "description": "Staleness guard. Pass the non-empty source fingerprint returned by the current flow.search result verbatim. Omit this field when no fingerprint is available; never send an empty or invented value."}
220 },
221 "required": ["ref"],
222 "additionalProperties": false
223 })
224 }
225
226 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
227 Box::pin(async move {
228 let flow_ref = string_arg(&args, "ref", "flow.describe")?;
229 let expected_version = optional_string_arg(&args, "version", "flow.describe")?;
230 describe_catalog_entry(&FlowCatalog::load()?, flow_ref, expected_version.as_deref())
231 })
232 }
233}
234
235fn search_catalog(
236 catalog: &FlowCatalog,
237 query: &str,
238 limit: usize,
239 cursor: Option<&str>,
240) -> ToolResult {
241 let normalized_query = query.trim().to_lowercase();
242 let search_fingerprint = search_fingerprint(&catalog.fingerprint, &normalized_query);
243 let offset = cursor
244 .map(|cursor| parse_cursor(cursor, &search_fingerprint))
245 .transpose()?
246 .unwrap_or(0);
247 let mut entries = catalog
248 .searchable_entries()
249 .into_iter()
250 .filter_map(|flow| search_score(flow, &normalized_query).map(|score| (score, flow)))
251 .collect::<Vec<_>>();
252 entries.sort_by(|(left_score, left), (right_score, right)| {
253 right_score
254 .cmp(left_score)
255 .then_with(|| left.reference.cmp(&right.reference))
256 });
257 if offset > entries.len() {
258 return Err(RuntimeError::ToolFailed(
259 "flow.search: cursor offset is outside the result set".into(),
260 ));
261 }
262 let end = (offset + limit).min(entries.len());
263 let items = entries[offset..end]
264 .iter()
265 .map(|(_, flow)| {
266 Value::Struct(vec![
267 ("ref".into(), Value::Str(flow.reference.clone())),
268 ("version".into(), Value::Str(flow.version.clone())),
269 ("summary".into(), Value::Str(flow.summary.clone())),
270 ])
271 })
272 .collect();
273 let next_cursor = if end < entries.len() {
274 Value::Str(format!("{search_fingerprint}:{end}"))
275 } else {
276 Value::Unit
277 };
278 Ok(Value::Struct(vec![
279 ("items".into(), Value::List(items)),
280 ("next_cursor".into(), next_cursor),
281 ("total".into(), Value::Int(entries.len() as i64)),
282 (
283 "catalog_fingerprint".into(),
284 Value::Str(catalog.fingerprint.clone()),
285 ),
286 ]))
287}
288
289fn search_score(flow: &FlowEntry, query: &str) -> Option<u32> {
290 if query.is_empty() {
291 return Some(0);
292 }
293 let name = flow.name.to_lowercase();
294 let reference = flow.reference.to_lowercase();
295 let summary = flow.summary.to_lowercase();
296 let mut score = if name.contains(query) || reference.contains(query) || summary.contains(query)
297 {
298 100
299 } else {
300 0
301 };
302 for token in query
303 .split(|character: char| !character.is_alphanumeric())
304 .filter(|token| !token.is_empty())
305 {
306 if name == token {
307 score += 24;
308 } else if name.contains(token) {
309 score += 12;
310 }
311 if reference.contains(token) {
312 score += 8;
313 }
314 if summary.contains(token) {
315 score += 4;
316 }
317 }
318 (score > 0).then_some(score)
319}
320
321fn describe_catalog_entry(
322 catalog: &FlowCatalog,
323 flow_ref: &str,
324 expected_version: Option<&str>,
325) -> ToolResult {
326 let flow = catalog.find(flow_ref).ok_or_else(|| {
327 RuntimeError::ToolFailed(format!("flow.describe: flow `{flow_ref}` not found"))
328 })?;
329 if expected_version.is_some_and(|version| version != flow.version) {
330 return Err(RuntimeError::ToolFailed(format!(
331 "flow.describe: stale version for `{}`; search again",
332 flow.reference
333 )));
334 }
335 Ok(flow_detail_value(flow))
336}
337
338fn scan_flow_file(path: &Path) -> Result<FlowFile, RuntimeError> {
339 let source = std::fs::read_to_string(path).map_err(|error| {
340 RuntimeError::ToolFailed(format!("flow catalog: read {}: {error}", path.display()))
341 })?;
342 let parsed = atman_dsl::parse::parse_file(&source).map_err(|error| {
343 RuntimeError::ToolFailed(format!("flow catalog: parse {}: {error}", path.display()))
344 })?;
345 let file_name = path
346 .file_name()
347 .and_then(|name| name.to_str())
348 .unwrap_or_default()
349 .to_string();
350 let description = parsed
351 .flows
352 .iter()
353 .find(|flow| flow.name.name == "describe")
354 .and_then(extract_return_string_literal)
355 .unwrap_or_default();
356 let version = format!("blake3:{}", blake3::hash(source.as_bytes()).to_hex());
357 let flows = parsed
358 .flows
359 .iter()
360 .map(|flow| flow_entry(&file_name, &description, &version, flow))
361 .collect();
362 Ok(FlowFile {
363 name: file_name,
364 description,
365 flows,
366 })
367}
368
369fn flow_entry(file_name: &str, description: &str, version: &str, flow: &FlowDecl) -> FlowEntry {
370 FlowEntry {
371 name: flow.name.name.clone(),
372 reference: format!("{file_name}@{}", flow.name.name),
373 version: version.to_string(),
374 summary: description.to_string(),
375 params: flow
376 .params
377 .iter()
378 .map(|parameter| FlowParameter {
379 name: parameter.name.name.clone(),
380 ty: render_type(¶meter.ty),
381 default: parameter.default.as_ref().map(expr_to_value),
382 })
383 .collect(),
384 }
385}
386
387fn flow_detail_value(flow: &FlowEntry) -> Value {
388 Value::Struct(vec![
389 ("name".into(), Value::Str(flow.name.clone())),
390 ("ref".into(), Value::Str(flow.reference.clone())),
391 ("version".into(), Value::Str(flow.version.clone())),
392 ("summary".into(), Value::Str(flow.summary.clone())),
393 (
394 "params".into(),
395 Value::List(
396 flow.params
397 .iter()
398 .map(|parameter| {
399 Value::Struct(vec![
400 ("name".into(), Value::Str(parameter.name.clone())),
401 ("type".into(), Value::Str(parameter.ty.clone())),
402 ("required".into(), Value::Bool(parameter.default.is_none())),
403 (
404 "default".into(),
405 parameter.default.clone().unwrap_or(Value::Unit),
406 ),
407 ])
408 })
409 .collect(),
410 ),
411 ),
412 ])
413}
414
415fn legacy_flow_value(flow: &FlowEntry) -> Value {
416 Value::Struct(vec![
417 ("name".into(), Value::Str(flow.name.clone())),
418 ("ref".into(), Value::Str(flow.reference.clone())),
419 (
420 "params".into(),
421 Value::List(
422 flow.params
423 .iter()
424 .map(|parameter| {
425 Value::Struct(vec![
426 ("name".into(), Value::Str(parameter.name.clone())),
427 ("ty".into(), Value::Str(parameter.ty.clone())),
428 (
429 "default".into(),
430 parameter.default.clone().unwrap_or(Value::Unit),
431 ),
432 ])
433 })
434 .collect(),
435 ),
436 ),
437 ])
438}
439
440fn normalize_flow_ref(flow_ref: &str) -> Option<String> {
441 let (file, flow) = flow_ref.split_once('@')?;
442 if file.is_empty() || flow.is_empty() {
443 return None;
444 }
445 let file = normalize_flow_file(file)?;
446 Some(format!("{file}@{flow}"))
447}
448
449fn normalize_flow_file(file: &str) -> Option<String> {
450 let file = file.trim();
451 if file.is_empty() {
452 return None;
453 }
454 Some(if file.ends_with(".at") {
455 file.to_string()
456 } else {
457 format!("{file}.at")
458 })
459}
460
461fn search_fingerprint(catalog_fingerprint: &str, query: &str) -> String {
462 let mut hasher = blake3::Hasher::new();
463 hasher.update(catalog_fingerprint.as_bytes());
464 hasher.update(&[0]);
465 hasher.update(query.as_bytes());
466 format!("blake3:{}", hasher.finalize().to_hex())
467}
468
469fn parse_cursor(cursor: &str, expected_fingerprint: &str) -> Result<usize, RuntimeError> {
470 let (fingerprint, offset) = cursor.rsplit_once(':').ok_or_else(|| {
471 RuntimeError::ToolFailed("flow.search: malformed cursor; search again".into())
472 })?;
473 if fingerprint != expected_fingerprint {
474 return Err(RuntimeError::ToolFailed(
475 "flow.search: stale cursor; search again".into(),
476 ));
477 }
478 offset
479 .parse::<usize>()
480 .map_err(|_| RuntimeError::ToolFailed("flow.search: malformed cursor; search again".into()))
481}
482
483fn string_arg<'a>(args: &'a ToolArgs, name: &str, tool: &str) -> Result<&'a str, RuntimeError> {
484 match args.named(name) {
485 Some(Value::Str(value)) => Ok(value),
486 Some(other) => Err(RuntimeError::ToolFailed(format!(
487 "{tool}: `{name}` must be a string, got {}",
488 other.kind_name()
489 ))),
490 None => Err(RuntimeError::MissingArg(name.to_string())),
491 }
492}
493
494fn optional_string_arg(
495 args: &ToolArgs,
496 name: &str,
497 tool: &str,
498) -> Result<Option<String>, RuntimeError> {
499 match args.named(name) {
500 Some(Value::Str(value)) if value.trim().is_empty() => Ok(None),
501 Some(Value::Str(value)) => Ok(Some(value.clone())),
502 Some(Value::Unit) | None => Ok(None),
503 Some(other) => Err(RuntimeError::ToolFailed(format!(
504 "{tool}: `{name}` must be a string, got {}",
505 other.kind_name()
506 ))),
507 }
508}
509
510fn limit_arg(args: &ToolArgs, tool: &str) -> Result<usize, RuntimeError> {
511 match args.named("limit") {
512 None | Some(Value::Unit) => Ok(DEFAULT_SEARCH_LIMIT),
513 Some(Value::Int(limit)) if (1..=MAX_SEARCH_LIMIT as i64).contains(limit) => {
514 Ok(*limit as usize)
515 }
516 Some(Value::Int(_)) => Err(RuntimeError::ToolFailed(format!(
517 "{tool}: `limit` must be between 1 and {MAX_SEARCH_LIMIT}"
518 ))),
519 Some(other) => Err(RuntimeError::ToolFailed(format!(
520 "{tool}: `limit` must be an integer, got {}",
521 other.kind_name()
522 ))),
523 }
524}
525
526fn extract_return_string_literal(flow: &FlowDecl) -> Option<String> {
527 flow.body.iter().find_map(|statement| match statement {
528 Stmt::Return {
529 value: Expr::Literal(Literal::Str(value)),
530 } => Some(value.clone()),
531 _ => None,
532 })
533}
534
535pub(super) fn render_type(ty: &TypeExpr) -> String {
536 match ty {
537 TypeExpr::Named(name) => name.name.clone(),
538 TypeExpr::List(inner) => format!("[{}]", render_type(inner)),
539 TypeExpr::Struct(fields) => format!(
540 "{{{}}}",
541 fields
542 .iter()
543 .map(|(name, ty)| format!("{}: {}", name.name, render_type(ty)))
544 .collect::<Vec<_>>()
545 .join(", ")
546 ),
547 }
548}
549
550fn expr_to_value(expr: &Expr) -> Value {
551 match expr {
552 Expr::Literal(Literal::Int(value)) => Value::Int(*value),
553 Expr::Literal(Literal::Float(value)) => Value::Float(*value),
554 Expr::Literal(Literal::Bool(value)) => Value::Bool(*value),
555 Expr::Literal(Literal::Str(value)) => Value::Str(value.clone()),
556 _ => Value::Unit,
557 }
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 fn write_flow(dir: &Path, name: &str, source: &str) {
565 std::fs::write(dir.join(name), source).unwrap();
566 }
567
568 fn next_cursor(value: &Value) -> Option<&str> {
569 match value.field("next_cursor") {
570 Some(Value::Str(cursor)) => Some(cursor),
571 _ => None,
572 }
573 }
574
575 #[test]
576 fn optional_discovery_values_treat_blank_strings_as_omitted() {
577 for value in ["", " ", "\t\n"] {
578 let args = ToolArgs {
579 named: vec![("value".into(), Value::Str(value.into()))],
580 ..Default::default()
581 };
582 assert_eq!(
583 optional_string_arg(&args, "value", "flow.test").unwrap(),
584 None
585 );
586 }
587
588 let args = ToolArgs {
589 named: vec![("value".into(), Value::Str("blake3:123".into()))],
590 ..Default::default()
591 };
592 assert_eq!(
593 optional_string_arg(&args, "value", "flow.test").unwrap(),
594 Some("blake3:123".into())
595 );
596 }
597
598 #[test]
599 fn search_is_bounded_and_cursor_is_bound_to_catalog_revision() {
600 let dir = tempfile::tempdir().unwrap();
601 write_flow(
602 dir.path(),
603 "agents.at",
604 r#"
605flow describe() -> string { return "Delegated work" }
606flow alpha(goal: string) -> string { return goal }
607flow beta(goal: string) -> string { return goal }
608flow gamma(goal: string) -> string { return goal }
609"#,
610 );
611 let catalog = FlowCatalog::load_from(dir.path()).unwrap();
612 let first = search_catalog(&catalog, "", 2, None).unwrap();
613 let cursor = next_cursor(&first).unwrap().to_string();
614 assert_eq!(
615 first.field("items").and_then(|items| match items {
616 Value::List(items) => Some(items.len()),
617 _ => None,
618 }),
619 Some(2)
620 );
621 let second = search_catalog(&catalog, "", 2, Some(&cursor)).unwrap();
622 assert!(next_cursor(&second).is_none());
623
624 write_flow(
625 dir.path(),
626 "extra.at",
627 "flow delta(goal: string) -> string { return goal }",
628 );
629 let changed = FlowCatalog::load_from(dir.path()).unwrap();
630 let error = search_catalog(&changed, "", 2, Some(&cursor)).unwrap_err();
631 assert!(error.to_string().contains("stale cursor"));
632 }
633
634 #[test]
635 fn describe_returns_parameter_contract_and_rejects_stale_version() {
636 let dir = tempfile::tempdir().unwrap();
637 write_flow(
638 dir.path(),
639 "review.at",
640 r#"
641flow describe() -> string { return "Review code" }
642flow review(goal: string, retries: int = 3) -> string { return goal }
643"#,
644 );
645 let catalog = FlowCatalog::load_from(dir.path()).unwrap();
646 let flow = catalog.find("review@review").unwrap();
647 let described =
648 describe_catalog_entry(&catalog, "review.at@review", Some(&flow.version)).unwrap();
649 assert_eq!(
650 described.field("summary").and_then(as_str),
651 Some("Review code")
652 );
653 let params = match described.field("params") {
654 Some(Value::List(params)) => params,
655 _ => panic!("params must be a list"),
656 };
657 assert_eq!(params.len(), 2);
658 assert_eq!(params[0].field("required").and_then(as_bool), Some(true));
659 assert_eq!(params[1].field("default").and_then(as_int), Some(3));
660
661 let error =
662 describe_catalog_entry(&catalog, "review@review", Some("blake3:stale")).unwrap_err();
663 assert!(error.to_string().contains("stale version"));
664 }
665
666 #[test]
667 fn search_ranks_partial_natural_language_matches() {
668 let dir = tempfile::tempdir().unwrap();
669 write_flow(
670 dir.path(),
671 "subagent.at",
672 r#"
673flow describe() -> string { return "Sub-agent flows for isolated research, verification, implementation, and review. The research role reads files without making changes." }
674flow subagent(goal: string, role: string = "research") -> string { return goal }
675"#,
676 );
677 write_flow(
678 dir.path(),
679 "review.at",
680 r#"
681flow describe() -> string { return "Review files" }
682flow review(goal: string) -> string { return goal }
683"#,
684 );
685 let catalog = FlowCatalog::load_from(dir.path()).unwrap();
686 let result = search_catalog(&catalog, "subagent research read files", 5, None).unwrap();
687 let items = match result.field("items") {
688 Some(Value::List(items)) => items,
689 _ => panic!("items must be a list"),
690 };
691 assert_eq!(
692 items[0].field("ref").and_then(as_str),
693 Some("subagent.at@subagent")
694 );
695 }
696
697 #[test]
698 fn describe_accepts_the_same_file_shorthand_as_spawn() {
699 let dir = tempfile::tempdir().unwrap();
700 write_flow(
701 dir.path(),
702 "subagent.at",
703 r#"
704flow describe() -> string { return "Delegated work" }
705flow subagent(goal: string) -> string { return goal }
706flow research_loop(goal: string) -> string { return goal }
707"#,
708 );
709 let catalog = FlowCatalog::load_from(dir.path()).unwrap();
710 let described = describe_catalog_entry(&catalog, "subagent.at", None).unwrap();
711 assert_eq!(
712 described.field("ref").and_then(as_str),
713 Some("subagent.at@subagent")
714 );
715 }
716
717 fn as_str(value: &Value) -> Option<&str> {
718 match value {
719 Value::Str(value) => Some(value),
720 _ => None,
721 }
722 }
723
724 fn as_bool(value: &Value) -> Option<bool> {
725 match value {
726 Value::Bool(value) => Some(*value),
727 _ => None,
728 }
729 }
730
731 fn as_int(value: &Value) -> Option<i64> {
732 match value {
733 Value::Int(value) => Some(*value),
734 _ => None,
735 }
736 }
737}