Skip to main content

lean_ctx/tools/
ctx_control.rs

1//! ctx_control -- Universal context manipulation tool.
2//!
3//! Single entry point for include/exclude/pin/rewrite/set_view operations.
4//! Delegates to the Overlay and Ledger systems.
5
6use serde_json::Value;
7
8use crate::core::context_field::{ContextItemId, ContextState, ViewKind};
9use crate::core::context_ledger::ContextLedger;
10use crate::core::context_overlay::{
11    ContextOverlay, OverlayAuthor, OverlayId, OverlayOp, OverlayScope, OverlayStore,
12};
13
14pub fn handle(
15    args: Option<&serde_json::Map<String, Value>>,
16    ledger: &mut ContextLedger,
17    overlays: &mut OverlayStore,
18) -> String {
19    let action = get_str(args, "action").unwrap_or_default();
20    let target = get_str(args, "target").unwrap_or_default();
21    let value = get_str(args, "value");
22    let scope_str = get_str(args, "scope").unwrap_or_else(|| "session".to_string());
23    let reason = get_str(args, "reason").unwrap_or_else(|| action.clone());
24
25    let scope = match scope_str.as_str() {
26        "call" => OverlayScope::Call,
27        "project" => OverlayScope::Project,
28        "global" => OverlayScope::Global,
29        _ => OverlayScope::Session,
30    };
31
32    let item_id = resolve_target(&target, ledger);
33
34    match action.as_str() {
35        "exclude" => {
36            let op = OverlayOp::Exclude { reason: reason.clone() };
37            apply_overlay(overlays, &item_id, op, scope);
38            ledger.set_state(&target, ContextState::Excluded);
39            format!("[ctx_control] excluded {target}: {reason}")
40        }
41        "include" => {
42            let op = OverlayOp::Include;
43            apply_overlay(overlays, &item_id, op, scope);
44            ledger.set_state(&target, ContextState::Included);
45            format!("[ctx_control] included {target}")
46        }
47        "pin" => {
48            let verbatim = value.as_deref() == Some("verbatim");
49            let op = OverlayOp::Pin { verbatim };
50            apply_overlay(overlays, &item_id, op, scope);
51            ledger.set_state(&target, ContextState::Pinned);
52            format!("[ctx_control] pinned {target}")
53        }
54        "unpin" => {
55            let op = OverlayOp::Unpin;
56            apply_overlay(overlays, &item_id, op, scope);
57            ledger.set_state(&target, ContextState::Included);
58            format!("[ctx_control] unpinned {target}")
59        }
60        "set_view" => {
61            let view_str = value.unwrap_or_else(|| "full".to_string());
62            let view = ViewKind::parse(&view_str);
63            let op = OverlayOp::SetView(view);
64            apply_overlay(overlays, &item_id, op, scope);
65            format!("[ctx_control] set view for {target} to {view_str}")
66        }
67        "set_priority" => {
68            let priority: f64 = value
69                .as_deref()
70                .and_then(|v| v.parse().ok())
71                .unwrap_or(0.5);
72            let op = OverlayOp::SetPriority(priority);
73            apply_overlay(overlays, &item_id, op, scope);
74            ledger.update_phi(&target, priority);
75            format!("[ctx_control] set priority for {target} to {priority:.2}")
76        }
77        "mark_outdated" => {
78            let op = OverlayOp::MarkOutdated;
79            apply_overlay(overlays, &item_id, op, scope);
80            ledger.set_state(&target, ContextState::Stale);
81            format!("[ctx_control] marked {target} as outdated")
82        }
83        "reset" => {
84            overlays.remove_for_item(&item_id);
85            ledger.set_state(&target, ContextState::Included);
86            format!("[ctx_control] reset all overlays for {target}")
87        }
88        "list" => {
89            let items = overlays.all();
90            if items.is_empty() {
91                "[ctx_control] no active overlays".to_string()
92            } else {
93                let mut out = format!("[ctx_control] {} active overlays:\n", items.len());
94                for ov in items {
95                    let stale_tag = if ov.stale { " [STALE]" } else { "" };
96                    out.push_str(&format!(
97                        "  {} -> {:?} ({:?}){}\n",
98                        ov.target, ov.operation, ov.scope, stale_tag
99                    ));
100                }
101                out
102            }
103        }
104        "history" => {
105            let history = overlays.for_item(&item_id);
106            if history.is_empty() {
107                format!("[ctx_control] no overlay history for {target}")
108            } else {
109                let mut out = format!(
110                    "[ctx_control] {} overlays for {target}:\n",
111                    history.len()
112                );
113                for ov in history {
114                    out.push_str(&format!(
115                        "  {} {:?} at {} ({:?})\n",
116                        ov.id, ov.operation, ov.created_at, ov.scope
117                    ));
118                }
119                out
120            }
121        }
122        _ => format!("[ctx_control] unknown action: {action}. valid: exclude|include|pin|unpin|set_view|set_priority|mark_outdated|reset|list|history"),
123    }
124}
125
126fn resolve_target(target: &str, ledger: &ContextLedger) -> ContextItemId {
127    if target.starts_with("file:")
128        || target.starts_with("shell:")
129        || target.starts_with("knowledge:")
130    {
131        ContextItemId(target.to_string())
132    } else if let Some(stripped) = target.strip_prefix('@') {
133        ContextItemId::from_file(stripped)
134    } else if let Some(entry) = ledger.entries.iter().find(|e| e.path == target) {
135        entry
136            .id
137            .clone()
138            .unwrap_or_else(|| ContextItemId::from_file(target))
139    } else {
140        ContextItemId::from_file(target)
141    }
142}
143
144fn apply_overlay(
145    overlays: &mut OverlayStore,
146    item_id: &ContextItemId,
147    operation: OverlayOp,
148    scope: OverlayScope,
149) {
150    let overlay = ContextOverlay {
151        id: OverlayId::generate(item_id),
152        target: item_id.clone(),
153        operation,
154        scope,
155        before_hash: String::new(),
156        author: OverlayAuthor::Agent("mcp".to_string()),
157        created_at: chrono::Utc::now(),
158        stale: false,
159    };
160    overlays.add(overlay);
161}
162
163fn get_str(args: Option<&serde_json::Map<String, Value>>, key: &str) -> Option<String> {
164    args?
165        .get(key)?
166        .as_str()
167        .map(std::string::ToString::to_string)
168}