Skip to main content

lean_ctx/tools/
ctx_session.rs

1use crate::core::session::SessionState;
2
3#[derive(Clone, Copy, Debug)]
4pub struct SessionToolOptions<'a> {
5    pub format: Option<&'a str>,
6    pub path: Option<&'a str>,
7    pub write: bool,
8    pub privacy: Option<&'a str>,
9    /// For `action=configure`: set terse output mode when `Some`.
10    pub terse: Option<bool>,
11}
12
13pub fn handle(
14    session: &mut SessionState,
15    tool_calls: &[(String, u64)],
16    action: &str,
17    value: Option<&str>,
18    session_id: Option<&str>,
19    opts: SessionToolOptions<'_>,
20) -> String {
21    match action {
22        "status" => session.format_compact(),
23
24        "load" => {
25            let loaded = if let Some(id) = session_id {
26                SessionState::load_by_id(id)
27            } else {
28                SessionState::load_latest()
29            };
30
31            if let Some(prev) = loaded {
32                let summary = prev.format_compact();
33                *session = prev;
34                format!("Session loaded.\n{summary}")
35            } else {
36                let id_str = session_id.unwrap_or("latest");
37                format!("No session found (id: {id_str}). Starting fresh.")
38            }
39        }
40
41        "save" => {
42            match session.save() {
43                Ok(()) => format!("Session {} saved (v{}).", session.id, session.version),
44                Err(e) => format!("Save failed: {e}"),
45            }
46        }
47
48        "export" => {
49            let requested_privacy = crate::core::ccp_session_bundle::BundlePrivacyV1::parse(opts.privacy);
50            if requested_privacy == crate::core::ccp_session_bundle::BundlePrivacyV1::Full
51                && crate::core::roles::active_role_name() != "admin"
52            {
53                return "ERROR: privacy=full requires role 'admin'.".to_string();
54            }
55
56            let bundle =
57                crate::core::ccp_session_bundle::build_bundle_v1(session, requested_privacy);
58            let json = match crate::core::ccp_session_bundle::serialize_bundle_v1_pretty(&bundle) {
59                Ok(s) => s,
60                Err(e) => return e,
61            };
62
63            let format = opts
64                .format
65                .unwrap_or(if opts.write { "summary" } else { "json" });
66            let root = session.project_root.clone().unwrap_or_else(|| {
67                std::env::current_dir().map_or_else(
68                    |_| ".".to_string(),
69                    |p| p.to_string_lossy().to_string(),
70                )
71            });
72            let root_path = std::path::PathBuf::from(&root);
73
74            let mut written: Option<String> = None;
75            if opts.write || opts.path.is_some() {
76                let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
77                let candidate = if let Some(p) = opts.path.or(value) {
78                    let p = std::path::PathBuf::from(p);
79                    if p.is_absolute() { p } else { root_path.join(p) }
80                } else {
81                    root_path
82                        .join(".lean-ctx")
83                        .join("session_bundles")
84                        .join(format!("ccp-session-bundle-v1_{}_{}.json", bundle.session.id, ts))
85                };
86
87                let jailed = match crate::core::io_boundary::jail_and_check_path(
88                    "ctx_session.export",
89                    candidate.as_path(),
90                    root_path.as_path(),
91                ) {
92                    Ok((p, _warning)) => p,
93                    Err(e) => return e,
94                };
95
96                if let Err(e) = crate::core::ccp_session_bundle::write_bundle_v1(&jailed, &json) {
97                    return format!("Export write failed: {e}");
98                }
99                written = Some(jailed.to_string_lossy().to_string());
100            }
101
102            match format {
103                "summary" => {
104                    let mut out = format!(
105                        "CCP session bundle exported (v{}).\n\
106schema_version: {}\n\
107session_id: {}\n\
108bytes: {}\n",
109                        bundle.session.version,
110                        bundle.schema_version,
111                        bundle.session.id,
112                        json.len()
113                    );
114                    if let Some(p) = written {
115                        out.push_str(&format!("path: {p}\n"));
116                    }
117                    if let Some(h) = bundle.project.project_root_hash {
118                        out.push_str(&format!("project_root_hash: {h}\n"));
119                    }
120                    if let Some(h) = bundle.project.project_identity_hash {
121                        out.push_str(&format!("project_identity_hash: {h}\n"));
122                    }
123                    out
124                }
125                _ => {
126                    if let Some(p) = written {
127                        format!("{json}\n\npath: {p}")
128                    } else {
129                        json
130                    }
131                }
132            }
133        }
134
135        "import" => {
136            let root = session.project_root.clone().unwrap_or_else(|| {
137                std::env::current_dir().map_or_else(
138                    |_| ".".to_string(),
139                    |p| p.to_string_lossy().to_string(),
140                )
141            });
142            let root_path = std::path::PathBuf::from(&root);
143
144            let Some(p) = opts.path.or(value) else {
145                return "ERROR: path is required for action=import".to_string();
146            };
147
148            let candidate = {
149                let p = std::path::PathBuf::from(p);
150                if p.is_absolute() { p } else { root_path.join(p) }
151            };
152            let jailed = match crate::core::io_boundary::jail_and_check_path(
153                "ctx_session.import",
154                candidate.as_path(),
155                root_path.as_path(),
156            ) {
157                Ok((p, _warning)) => p,
158                Err(e) => return e,
159            };
160
161            let bundle = match crate::core::ccp_session_bundle::read_bundle_v1(&jailed) {
162                Ok(b) => b,
163                Err(e) => return format!("Import failed: {e}"),
164            };
165
166            // Replayability hint: compare project identity hashes (best-effort).
167            let current_root_hash = crate::core::project_hash::hash_project_root(&root);
168            let current_identity_hash = crate::core::project_hash::project_identity(&root)
169                .as_deref()
170                .map(|s| {
171                    use md5::{Digest, Md5};
172                    let mut h = Md5::new();
173                    h.update(s.as_bytes());
174                    format!("{:x}", h.finalize())
175                });
176
177            let mut warning: Option<String> = None;
178            if let Some(ref exported) = bundle.project.project_root_hash {
179                if exported != &current_root_hash {
180                    warning = Some("WARNING: project_root_hash mismatch (importing into different project root).".to_string());
181                }
182            }
183            if let (Some(exported), Some(current)) =
184                (bundle.project.project_identity_hash.as_ref(), current_identity_hash.as_ref())
185            {
186                if exported != current {
187                    warning = Some("WARNING: project_identity_hash mismatch (importing into different project identity).".to_string());
188                }
189            }
190
191            let report = crate::core::ccp_session_bundle::import_bundle_v1_into_session(
192                session,
193                &bundle,
194                Some(&root),
195            );
196            let _ = session.save();
197
198            let mut out = format!(
199                "CCP session bundle imported.\n\
200session_id: {}\n\
201version: {}\n\
202files_touched: {}\n\
203stale_files: {}\n",
204                report.session_id, report.version, report.files_touched, report.stale_files
205            );
206            if let Some(w) = warning {
207                out.push_str(&format!("{w}\n"));
208            }
209            out
210        }
211
212        "task" => {
213            let desc = value.unwrap_or("(no description)");
214            session.set_task(desc, None);
215            format!("Task set: {desc}")
216        }
217
218        "finding" => {
219            let summary = value.unwrap_or("(no summary)");
220            let (file, line, text) = parse_finding_value(summary);
221            session.add_finding(file.as_deref(), line, text);
222            format!("Finding added: {summary}")
223        }
224
225        "decision" => {
226            let desc = value.unwrap_or("(no description)");
227            session.add_decision(desc, None);
228            format!("Decision recorded: {desc}")
229        }
230
231        "reset" => {
232            let _ = session.save();
233            let old_id = session.id.clone();
234            *session = SessionState::new();
235            crate::core::budget_tracker::BudgetTracker::global().reset();
236            if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
237                let radar_path = data_dir.join("context_radar.jsonl");
238                let prev = data_dir.join("context_radar.prev.jsonl");
239                let _ = std::fs::rename(&radar_path, &prev);
240            }
241            format!("Session reset. Previous: {old_id}. New: {}", session.id)
242        }
243
244        "list" => {
245            let sessions = SessionState::list_sessions();
246            if sessions.is_empty() {
247                return "No sessions found.".to_string();
248            }
249            let mut lines = vec![format!("Sessions ({}):", sessions.len())];
250            for s in sessions.iter().take(10) {
251                let task = s.task.as_deref().unwrap_or("(no task)");
252                let task_short: String = task.chars().take(40).collect();
253                lines.push(format!(
254                    "  {} v{} | {} calls | {} tok | {}",
255                    s.id, s.version, s.tool_calls, s.tokens_saved, task_short
256                ));
257            }
258            if sessions.len() > 10 {
259                lines.push(format!("  ... +{} more", sessions.len() - 10));
260            }
261            lines.join("\n")
262        }
263
264        "cleanup" => {
265            let removed = SessionState::cleanup_old_sessions(7);
266            format!("Cleaned up {removed} old session(s) (>7 days).")
267        }
268
269        "configure" => match opts.terse {
270            Some(enabled) => {
271                session.terse_mode = enabled;
272                session.increment();
273                format!("Session configured: terse_mode={enabled}")
274            }
275            None => format!("Session config: terse_mode={}", session.terse_mode),
276        },
277
278        "snapshot" => match session.save_compaction_snapshot() {
279            Ok(snapshot) => {
280                format!(
281                    "Compaction snapshot saved ({} bytes).\n{snapshot}",
282                    snapshot.len()
283                )
284            }
285            Err(e) => format!("Snapshot failed: {e}"),
286        },
287
288        "restore" => {
289            let snapshot = if let Some(id) = session_id {
290                SessionState::load_compaction_snapshot(id)
291            } else {
292                SessionState::load_latest_snapshot()
293            };
294            match snapshot {
295                Some(s) => format!("Session restored from compaction snapshot:\n{s}"),
296                None => "No compaction snapshot found. Session continues fresh.".to_string(),
297            }
298        }
299
300        "resume" => session.build_resume_block(),
301
302        "profile" => {
303            use crate::core::profiles;
304            if let Some(name) = value {
305                if let Ok(p) = profiles::set_active_profile(name) {
306                    format!(
307                        "Profile switched to '{name}'.\n\
308                         Read mode: {}, Budget: {} tokens, CRP: {}, Density: {}",
309                        p.read.default_mode_effective(),
310                        p.budget.max_context_tokens_effective(),
311                        p.compression.crp_mode_effective(),
312                        p.compression.output_density_effective(),
313                    )
314                } else {
315                    let available: Vec<String> =
316                        profiles::list_profiles().iter().map(|p| p.name.clone()).collect();
317                    format!(
318                        "Profile '{name}' not found. Available: {}",
319                        available.join(", ")
320                    )
321                }
322            } else {
323                let name = profiles::active_profile_name();
324                let p = profiles::active_profile();
325                let list = profiles::list_profiles();
326                let mut out = format!(
327                    "Active profile: {name}\n\
328                     Read: {}, Budget: {} tok, CRP: {}, Density: {}\n\n\
329                     Available profiles:",
330                    p.read.default_mode_effective(),
331                    p.budget.max_context_tokens_effective(),
332                    p.compression.crp_mode_effective(),
333                    p.compression.output_density_effective(),
334                );
335                for info in &list {
336                    let marker = if info.name == name { " *" } else { "  " };
337                    out.push_str(&format!(
338                        "\n{marker} {:<14} ({}) {}",
339                        info.name, info.source, info.description
340                    ));
341                }
342                out.push_str("\n\nSwitch: ctx_session action=profile value=<name>");
343                out
344            }
345        }
346
347        "budget" => {
348            use crate::core::budget_tracker::BudgetTracker;
349            let snap = BudgetTracker::global().check();
350            let mut out = snap.format_compact();
351
352            if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
353                let window = crate::core::context_radar::default_window_for_client("cursor");
354                let radar = crate::core::context_radar::ContextRadar::load(&data_dir, window);
355                let radar_display = radar.format_display();
356                if !radar_display.is_empty() {
357                    out.push_str("\n\n");
358                    out.push_str(&radar_display);
359                }
360            }
361            out
362        }
363
364        "role" => {
365            use crate::core::roles;
366            if let Some(name) = value {
367                match roles::set_active_role(name) {
368                    Ok(r) => {
369                        crate::core::budget_tracker::BudgetTracker::global().reset();
370                        format!(
371                            "Role switched to '{name}'.\n\
372                             Shell: {}, Budget: {} tokens / {} shell / ${:.2}\n\
373                             Tools: {}",
374                            r.role.shell_policy,
375                            r.limits.max_context_tokens,
376                            r.limits.max_shell_invocations,
377                            r.limits.max_cost_usd,
378                            if r.tools.allowed.iter().any(|a| a == "*") {
379                                let denied = if r.tools.denied.is_empty() {
380                                    "none".to_string()
381                                } else {
382                                    format!("denied: {}", r.tools.denied.join(", "))
383                                };
384                                format!("* (all), {denied}")
385                            } else {
386                                r.tools.allowed.join(", ")
387                            }
388                        )
389                    }
390                    Err(e) => {
391                        let available: Vec<String> =
392                            roles::list_roles().iter().map(|r| r.name.clone()).collect();
393                        format!("{e}. Available: {}", available.join(", "))
394                    }
395                }
396            } else {
397                let name = roles::active_role_name();
398                let r = roles::active_role();
399                let list = roles::list_roles();
400                let mut out = format!(
401                    "Active role: {name}\n\
402                     Description: {}\n\
403                     Shell policy: {}, Budget: {} tokens / {} shell / ${:.2}\n\n\
404                     Available roles:",
405                    r.role.description,
406                    r.role.shell_policy,
407                    r.limits.max_context_tokens,
408                    r.limits.max_shell_invocations,
409                    r.limits.max_cost_usd,
410                );
411                for info in &list {
412                    let marker = if info.is_active { " *" } else { "  " };
413                    out.push_str(&format!(
414                        "\n{marker} {:<14} ({}) {}",
415                        info.name, info.source, info.description
416                    ));
417                }
418                out.push_str("\n\nSwitch: ctx_session action=role value=<name>");
419                out
420            }
421        }
422
423        "diff" => {
424            let parts: Vec<&str> = value.unwrap_or("").split_whitespace().collect();
425            if parts.len() < 2 {
426                return "Usage: ctx_session diff <session_id_a> <session_id_b> [format]\n\
427                        Formats: summary (default), json\n\
428                        Example: ctx_session diff abc123 def456 json"
429                    .to_string();
430            }
431            let id_a = parts[0];
432            let id_b = parts[1];
433            let format = parts.get(2).copied().unwrap_or("summary");
434
435            let sess_a = SessionState::load_by_id(id_a);
436            let sess_b = SessionState::load_by_id(id_b);
437
438            match (sess_a, sess_b) {
439                (Some(a), Some(b)) => {
440                    let d = crate::core::session_diff::diff_sessions(&a, &b);
441                    match format {
442                        "json" => d.format_json(),
443                        _ => d.format_summary(),
444                    }
445                }
446                (None, _) => format!("Session not found: {id_a}"),
447                (_, None) => format!("Session not found: {id_b}"),
448            }
449        }
450
451        "slo" => {
452            match value {
453                Some("reload") => {
454                    crate::core::slo::reload();
455                    "SLO definitions reloaded from disk.".to_string()
456                }
457                Some("history") => {
458                    let hist = crate::core::slo::violation_history(20);
459                    if hist.is_empty() {
460                        "No SLO violations recorded.".to_string()
461                    } else {
462                        let mut out = format!("SLO violations (last {}):\n", hist.len());
463                        for v in &hist {
464                            out.push_str(&format!(
465                                "  {} {} ({}) {:.2} vs {:.2} → {}\n",
466                                v.timestamp, v.slo_name, v.metric, v.actual, v.threshold, v.action
467                            ));
468                        }
469                        out
470                    }
471                }
472                Some("clear") => {
473                    crate::core::slo::clear_violations();
474                    "SLO violation history cleared.".to_string()
475                }
476                _ => {
477                    let snap = crate::core::slo::evaluate_quiet();
478                    snap.format_compact()
479                }
480            }
481        }
482
483        "output_stats" => {
484            let snap = crate::core::output_verification::stats_snapshot();
485            snap.format_compact()
486        }
487
488        "verify" => {
489            let snap = crate::core::output_verification::stats_snapshot();
490            format!(
491                "DEPRECATION: action=\"verify\" is renamed to action=\"output_stats\" (ctx_verify is the full observability stack).\n{}",
492                snap.format_compact()
493            )
494        }
495
496        "episodes" => {
497            let project_root = session.project_root.clone().unwrap_or_else(|| {
498                std::env::current_dir().map_or_else(
499                    |_| "unknown".to_string(),
500                    |p| p.to_string_lossy().to_string(),
501                )
502            });
503            let policy = match crate::core::config::Config::load().memory_policy_effective() {
504                Ok(p) => p,
505                Err(e) => {
506                    let path = crate::core::config::Config::path().map_or_else(
507                        || "~/.lean-ctx/config.toml".to_string(),
508                        |p| p.display().to_string(),
509                    );
510                    return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
511                }
512            };
513            let hash = crate::core::project_hash::hash_project_root(&project_root);
514            let mut store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
515
516            match value {
517                Some("record") => {
518                    let ep = crate::core::episodic_memory::create_episode_from_session(
519                        session,
520                        tool_calls,
521                    );
522                    let id = ep.id.clone();
523                    store.record_episode(ep, &policy.episodic);
524                    if let Err(e) = store.save() {
525                        return format!("Episode record failed: {e}");
526                    }
527                    crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
528                        category: "episodic".to_string(),
529                        key: id.clone(),
530                        action: "record".to_string(),
531                    });
532                    format!("Episode recorded: {id}")
533                }
534                Some(v) if v.starts_with("search ") => {
535                    let q = v.trim_start_matches("search ").trim();
536                    let hits = store.search(q);
537                    if hits.is_empty() {
538                        return "No episodes matched.".to_string();
539                    }
540                    let mut out = format!("Episodes matched ({}):", hits.len());
541                    for ep in hits.into_iter().take(10) {
542                        let task: String = ep.task_description.chars().take(50).collect();
543                        out.push_str(&format!(
544                            "\n  {} | {} | {} | {}",
545                            ep.id,
546                            ep.timestamp,
547                            ep.outcome.label(),
548                            task
549                        ));
550                    }
551                    out
552                }
553                Some(v) if v.starts_with("file ") => {
554                    let f = v.trim_start_matches("file ").trim();
555                    let hits = store.by_file(f);
556                    let mut out = format!("Episodes for file match '{f}' ({}):", hits.len());
557                    for ep in hits.into_iter().take(10) {
558                        let task: String = ep.task_description.chars().take(50).collect();
559                        out.push_str(&format!(
560                            "\n  {} | {} | {} | {}",
561                            ep.id,
562                            ep.timestamp,
563                            ep.outcome.label(),
564                            task
565                        ));
566                    }
567                    out
568                }
569                Some(v) if v.starts_with("outcome ") => {
570                    let label = v.trim_start_matches("outcome ").trim();
571                    let hits = store.by_outcome(label);
572                    let mut out = format!("Episodes outcome '{label}' ({}):", hits.len());
573                    for ep in hits.into_iter().take(10) {
574                        let task: String = ep.task_description.chars().take(50).collect();
575                        out.push_str(&format!("\n  {} | {} | {}", ep.id, ep.timestamp, task));
576                    }
577                    out
578                }
579                _ => {
580                    let stats = store.stats();
581                    let recent = store.recent(10);
582                    let mut out = format!(
583                        "Episodic memory: {} episodes, success_rate={:.0}%, tokens_total={}\n\nRecent:",
584                        stats.total_episodes,
585                        stats.success_rate * 100.0,
586                        stats.total_tokens
587                    );
588                    for ep in recent {
589                        let task: String = ep.task_description.chars().take(60).collect();
590                        out.push_str(&format!(
591                            "\n  {} | {} | {} | {}",
592                            ep.id,
593                            ep.timestamp,
594                            ep.outcome.label(),
595                            task
596                        ));
597                    }
598                    out.push_str("\n\nActions: ctx_session action=episodes value=record|\"search <q>\"|\"file <path>\"|\"outcome success|failure|partial|unknown\"");
599                    out
600                }
601            }
602        }
603
604        "procedures" => {
605            let project_root = session.project_root.clone().unwrap_or_else(|| {
606                std::env::current_dir().map_or_else(
607                    |_| "unknown".to_string(),
608                    |p| p.to_string_lossy().to_string(),
609                )
610            });
611            let policy = match crate::core::config::Config::load().memory_policy_effective() {
612                Ok(p) => p,
613                Err(e) => {
614                    let path = crate::core::config::Config::path().map_or_else(
615                        || "~/.lean-ctx/config.toml".to_string(),
616                        |p| p.display().to_string(),
617                    );
618                    return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
619                }
620            };
621            let hash = crate::core::project_hash::hash_project_root(&project_root);
622            let episodes = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
623            let mut procs = crate::core::procedural_memory::ProceduralStore::load_or_create(&hash);
624
625            match value {
626                Some("detect") => {
627                    procs.detect_patterns(&episodes.episodes, &policy.procedural);
628                    if let Err(e) = procs.save() {
629                        return format!("Procedure detect failed: {e}");
630                    }
631                    crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
632                        category: "procedural".to_string(),
633                        key: hash.clone(),
634                        action: "detect".to_string(),
635                    });
636                    format!(
637                        "Procedures updated. Total procedures: {} (episodes: {}).",
638                        procs.procedures.len(),
639                        episodes.episodes.len()
640                    )
641                }
642                Some(v) if v.starts_with("suggest ") => {
643                    let task = v.trim_start_matches("suggest ").trim();
644                    let hits = procs.suggest(task);
645                    if hits.is_empty() {
646                        return "No procedures matched.".to_string();
647                    }
648                    let mut out = format!("Procedures suggested ({}):", hits.len());
649                    for p in hits.into_iter().take(10) {
650                        out.push_str(&format!(
651                            "\n  {} | conf={:.0}% | success={:.0}% | steps={}",
652                            p.name,
653                            p.confidence * 100.0,
654                            p.success_rate() * 100.0,
655                            p.steps.len()
656                        ));
657                    }
658                    out
659                }
660                _ => {
661                    let task = session
662                        .task
663                        .as_ref()
664                        .map(|t| t.description.clone())
665                        .unwrap_or_default();
666                    let suggestions = if task.is_empty() {
667                        Vec::new()
668                    } else {
669                        procs.suggest(&task)
670                    };
671
672                    let mut out = format!(
673                        "Procedural memory: {} procedures (episodes: {})",
674                        procs.procedures.len(),
675                        episodes.episodes.len()
676                    );
677
678                    if !task.is_empty() {
679                        out.push_str(&format!("\nTask: {}", task.chars().take(80).collect::<String>()));
680                        if !suggestions.is_empty() {
681                            out.push_str("\n\nSuggested:");
682                            for p in suggestions.into_iter().take(5) {
683                                out.push_str(&format!(
684                                    "\n  {} | conf={:.0}% | success={:.0}% | steps={}",
685                                    p.name,
686                                    p.confidence * 100.0,
687                                    p.success_rate() * 100.0,
688                                    p.steps.len()
689                                ));
690                            }
691                        }
692                    }
693
694                    out.push_str("\n\nActions: ctx_session action=procedures value=detect|\"suggest <task>\"");
695                    out
696                }
697            }
698        }
699
700        _ => format!("Unknown action: {action}. Use: status, load, save, task, finding, decision, reset, list, cleanup, snapshot, restore, resume, configure, profile, role, budget, slo, diff, output_stats, verify, export, import, episodes, procedures"),
701    }
702}
703
704fn parse_finding_value(value: &str) -> (Option<String>, Option<u32>, &str) {
705    // Format: "file.rs:42 — summary text" or just "summary text"
706    if let Some(dash_pos) = value.find(" \u{2014} ").or_else(|| value.find(" - ")) {
707        let location = &value[..dash_pos];
708        let sep_len = 3;
709        let text = &value[dash_pos + sep_len..];
710
711        if let Some(colon_pos) = location.rfind(':') {
712            let file = &location[..colon_pos];
713            if let Ok(line) = location[colon_pos + 1..].parse::<u32>() {
714                return (Some(file.to_string()), Some(line), text);
715            }
716        }
717        return (Some(location.to_string()), None, text);
718    }
719    (None, None, value)
720}