返回 CodeWhale
context_report.rs
根目录 / crates / tui / src / context_report.rs
1 //! Diagnostic prompt source map for context pressure reports.
2 //!
3 //! The report is intentionally approximate for v0.8.59. It uses the same
4 //! conservative token heuristic as compaction and describes the runtime sources
5 //! CodeWhale already tracks, without claiming provider-tokenizer parity.
6
7 use std::fmt::Write as _;
8 use std::path::Path;
9
10 use chrono::{SecondsFormat, Utc};
11 use serde::Serialize;
12
13 use crate::compaction::{estimate_input_tokens_conservative, estimate_text_tokens_conservative};
14 use crate::config::Config;
15 use crate::context_budget::PressureLevel;
16 use crate::prompts::{CORE_EXECUTION_PROFILE_PROMPT, Personality};
17 use crate::route_budget::route_context_window_tokens;
18 use crate::tui::app::App;
19 use codewhale_config::AppMode;
20 use codewhale_models::{CacheControl, ContentBlock, Message, SystemPrompt, Tool};
21
22 #[derive(Debug, Clone, Serialize)]
23 pub struct PromptSourceMap {
24 pub entries: Vec<SourceEntry>,
25 pub total_estimated_tokens: usize,
26 pub active_context_estimated_tokens: usize,
27 pub context_window_tokens: Option<u32>,
28 /// Non-secret receipt for the effective context-window value.
29 pub context_window_source: Option<String>,
30 pub budget_used_percent: Option<f64>,
31 pub generated_at: String,
32 pub note: String,
33 }
34
35 /// Inspectable request-prefix context for the current session.
36 ///
37 /// `PromptSourceMap` explains provenance and estimated pressure. This sibling
38 /// type exposes the current assembled system-prompt sections and most recently
39 /// sent model tool catalog so users can audit the prompt plumbing as JSON.
40 #[derive(Debug, Clone, Serialize)]
41 pub struct PromptContext {
42 pub schema_version: u8,
43 pub provider: String,
44 pub model: String,
45 pub system_prompt_state: &'static str,
46 pub tool_catalog_state: &'static str,
47 pub sections: Vec<PromptContextSection>,
48 pub tools: Vec<Tool>,
49 pub source_map: PromptSourceMap,
50 }
51
52 #[derive(Debug, Clone, Serialize)]
53 pub struct PromptContextSection {
54 pub index: usize,
55 pub block_type: String,
56 pub cache_control: Option<CacheControl>,
57 pub estimated_tokens: usize,
58 pub text: String,
59 }
60
61 #[derive(Debug, Clone, Serialize)]
62 pub struct SourceEntry {
63 pub source_kind: SourceKind,
64 pub label: String,
65 pub source_path: Option<String>,
66 pub activation_reason: ActivationReason,
67 pub estimated_tokens: usize,
68 pub counting_confidence: CountingConfidence,
69 pub authority_tier: Option<u8>,
70 pub truncation_reason: Option<String>,
71 }
72
73 impl SourceEntry {
74 fn text(
75 source_kind: SourceKind,
76 label: impl Into<String>,
77 source_path: Option<String>,
78 activation_reason: ActivationReason,
79 text: &str,
80 counting_confidence: CountingConfidence,
81 authority_tier: Option<u8>,
82 ) -> Self {
83 Self::estimate(
84 source_kind,
85 label,
86 source_path,
87 activation_reason,
88 estimate_text_tokens_conservative(text),
89 counting_confidence,
90 authority_tier,
91 )
92 }
93
94 fn estimate(
95 source_kind: SourceKind,
96 label: impl Into<String>,
97 source_path: Option<String>,
98 activation_reason: ActivationReason,
99 estimated_tokens: usize,
100 counting_confidence: CountingConfidence,
101 authority_tier: Option<u8>,
102 ) -> Self {
103 Self {
104 source_kind,
105 label: label.into(),
106 source_path,
107 activation_reason,
108 estimated_tokens,
109 counting_confidence,
110 authority_tier,
111 truncation_reason: None,
112 }
113 }
114
115 fn omitted(
116 source_kind: SourceKind,
117 label: impl Into<String>,
118 source_path: Option<String>,
119 authority_tier: Option<u8>,
120 reason: impl Into<String>,
121 ) -> Self {
122 Self {
123 source_kind,
124 label: label.into(),
125 source_path,
126 activation_reason: ActivationReason::Omitted,
127 estimated_tokens: 0,
128 counting_confidence: CountingConfidence::High,
129 authority_tier,
130 truncation_reason: Some(reason.into()),
131 }
132 }
133
134 fn diagnostic(
135 source_kind: SourceKind,
136 label: impl Into<String>,
137 source_path: Option<String>,
138 activation_reason: ActivationReason,
139 detail: impl Into<String>,
140 estimated_tokens: usize,
141 authority_tier: Option<u8>,
142 ) -> Self {
143 Self {
144 source_kind,
145 label: label.into(),
146 source_path,
147 activation_reason,
148 estimated_tokens,
149 counting_confidence: CountingConfidence::High,
150 authority_tier,
151 truncation_reason: Some(detail.into()),
152 }
153 }
154 }
155
156 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
157 #[serde(rename_all = "snake_case")]
158 pub enum SourceKind {
159 Constitution,
160 UserConstitution,
161 RepoConstitution,
162 ProjectContext,
163 ProjectContextWarning,
164 ProjectContextPack,
165 SkillsBlock,
166 ContextManagement,
167 CompactionRelayTemplate,
168 RuntimePolicy,
169 AuthorityRecap,
170 EnvironmentBlock,
171 UserMemory,
172 SessionGoal,
173 HandoffRelay,
174 ToolSchemas,
175 UserRequest,
176 ConversationHistory,
177 ToolResult,
178 ModelProviderFact,
179 }
180
181 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
182 #[serde(rename_all = "snake_case")]
183 pub enum ActivationReason {
184 AlwaysOn,
185 FilePresent,
186 ConfigEnabled,
187 RuntimeState,
188 PerRequest,
189 Omitted,
190 }
191
192 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
193 #[serde(rename_all = "snake_case")]
194 pub enum CountingConfidence {
195 High,
196 Approximate,
197 }
198
199 struct ReportBuilder {
200 entries: Vec<SourceEntry>,
201 }
202
203 impl ReportBuilder {
204 fn new() -> Self {
205 Self {
206 entries: Vec::new(),
207 }
208 }
209
210 fn push(&mut self, entry: SourceEntry) {
211 self.entries.push(entry);
212 }
213
214 /// The window arrives as one resolution rather than a number plus a
215 /// separately chosen label, so the report can never attribute one rung's
216 /// tokens to another rung.
217 fn finish(
218 self,
219 context_window: crate::route_runtime::ContextWindowResolution,
220 active_context_estimated_tokens: usize,
221 note: impl Into<String>,
222 ) -> PromptSourceMap {
223 let total_estimated_tokens = self
224 .entries
225 .iter()
226 .map(|entry| entry.estimated_tokens)
227 .sum();
228 let budget_used_percent =
229 ((active_context_estimated_tokens as f64 / f64::from(context_window.tokens)) * 100.0)
230 .clamp(0.0, 100.0);
231 PromptSourceMap {
232 entries: self.entries,
233 total_estimated_tokens,
234 active_context_estimated_tokens,
235 context_window_tokens: Some(context_window.tokens),
236 context_window_source: Some(context_window.source.label().to_string()),
237 budget_used_percent: Some(budget_used_percent),
238 generated_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
239 note: note.into(),
240 }
241 }
242 }
243
244 pub fn build_context_report(app: &App) -> PromptSourceMap {
245 // The host still stores the rung apart from the number; pair them against
246 // the same route limits the pressure meter reads.
247 let context_window = crate::route_runtime::ContextWindowResolution {
248 tokens: route_context_window_tokens(app.api_provider, &app.model, app.active_route_limits),
249 source: app.active_context_window_source,
250 };
251 let mut builder = base_source_entries(
252 &app.model,
253 &app.workspace,
254 Some(&app.skills_dir),
255 app.project_context_pack_enabled,
256 app.skills_scan_codewhale_only,
257 app.ui_locale.tag(),
258 app.mode,
259 Some(app.plugin_registry.as_ref()),
260 Some(context_window.tokens),
261 );
262 add_app_runtime_entries(&mut builder, app);
263 let active_context_estimated_tokens =
264 estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref());
265 builder.finish(
266 context_window,
267 active_context_estimated_tokens,
268 "Diagnostic source map. Token counts are conservative estimates and may differ from provider billing.",
269 )
270 }
271
272 #[must_use]
273 pub fn build_prompt_context(app: &App) -> PromptContext {
274 let tool_catalog_state = if app.session.last_tool_catalog.is_some() {
275 "last_sent"
276 } else {
277 "not_yet_sent"
278 };
279 let sections = match app.system_prompt.as_ref() {
280 Some(SystemPrompt::Text(text)) => vec![PromptContextSection {
281 index: 0,
282 block_type: "text".to_string(),
283 cache_control: None,
284 estimated_tokens: estimate_text_tokens_conservative(text),
285 text: text.clone(),
286 }],
287 Some(SystemPrompt::Blocks(blocks)) => blocks
288 .iter()
289 .enumerate()
290 .map(|(index, block)| PromptContextSection {
291 index,
292 block_type: block.block_type.clone(),
293 cache_control: block.cache_control.clone(),
294 estimated_tokens: estimate_text_tokens_conservative(&block.text),
295 text: block.text.clone(),
296 })
297 .collect(),
298 None => Vec::new(),
299 };
300 PromptContext {
301 schema_version: 1,
302 provider: app.api_provider.as_str().to_string(),
303 model: app.model.clone(),
304 system_prompt_state: "current_session",
305 tool_catalog_state,
306 sections,
307 tools: app.session.last_tool_catalog.clone().unwrap_or_default(),
308 source_map: build_context_report(app),
309 }
310 }
311
312 pub fn build_headless_context_report(config: &Config, workspace: &Path) -> PromptSourceMap {
313 let model = config.default_model();
314 let provider = config.api_provider();
315 let provider_identity = config.provider_identity_for(provider);
316 let route = crate::route_runtime::resolve_runtime_route(config, provider, Some(&model)).ok();
317 // A route we could not resolve does not erase an operator-configured
318 // window: doctor must report the same number the session would use.
319 let context_window = route.as_ref().map_or_else(
320 || {
321 crate::route_runtime::resolve_context_window(
322 provider,
323 &model,
324 None,
325 config.context_window_for_provider_config(provider),
326 config.model_context_windows_for(provider),
327 )
328 },
329 |route| route.context_window,
330 );
331 let global_skills_dir = config.skills_dir();
332 let selected_skills_dir =
333 crate::tui::app::resolve_skills_dir(workspace, &global_skills_dir, config);
334 let mut builder = base_source_entries(
335 &model,
336 workspace,
337 Some(&selected_skills_dir),
338 config.project_context_pack_enabled(),
339 config.skills_config().scan_codewhale_only(),
340 "en",
341 AppMode::Agent,
342 None,
343 Some(context_window.tokens),
344 );
345 let memory_path = config.memory_path();
346 let memory_enabled = config.memory_enabled();
347
348 if let Some(memory_block) =
349 crate::native_memory::native_prompt_block(memory_enabled, &memory_path, workspace)
350 {
351 builder.push(SourceEntry::text(
352 SourceKind::UserMemory,
353 "User memory",
354 Some(memory_path.display().to_string()),
355 ActivationReason::ConfigEnabled,
356 &memory_block,
357 CountingConfidence::High,
358 Some(6),
359 ));
360 } else {
361 builder.push(SourceEntry::omitted(
362 SourceKind::UserMemory,
363 "User memory",
364 Some(memory_path.display().to_string()),
365 Some(6),
366 "disabled, missing, or empty",
367 ));
368 }
369
370 builder.push(SourceEntry::text(
371 SourceKind::ModelProviderFact,
372 format!("Provider facts ({provider_identity})"),
373 None,
374 ActivationReason::RuntimeState,
375 &format!(
376 "provider: {}\nmodel: {}\ncontext_window: {}\ncontext_window_source: {}",
377 provider_identity,
378 model,
379 context_window.tokens,
380 context_window.source.label()
381 ),
382 CountingConfidence::Approximate,
383 None,
384 ));
385
386 let active_context_estimated_tokens = builder
387 .entries
388 .iter()
389 .map(|entry| entry.estimated_tokens)
390 .sum();
391 builder.finish(
392 context_window,
393 active_context_estimated_tokens,
394 "Headless diagnostic source map. Conversation, tool results, and live TUI state are unavailable in doctor mode.",
395 )
396 }
397
398 #[allow(clippy::too_many_arguments)]
399 fn base_source_entries(
400 model: &str,
401 workspace: &Path,
402 skills_dir: Option<&Path>,
403 project_pack_enabled: bool,
404 skills_scan_codewhale_only: bool,
405 locale_tag: &str,
406 mode: AppMode,
407 plugin_registry: Option<&crate::plugins::PluginRegistry>,
408 context_window_tokens: Option<u32>,
409 ) -> ReportBuilder {
410 let mut builder = ReportBuilder::new();
411
412 let constitution = crate::prompts::compose_default_static_layers(Personality::Calm, model);
413 builder.push(SourceEntry::text(
414 SourceKind::Constitution,
415 "Bundled constitution, language policy, and output policy",
416 Some(crate::prompts::base_prompt_origin().label().to_string()),
417 ActivationReason::AlwaysOn,
418 &constitution,
419 CountingConfidence::High,
420 Some(1),
421 ));
422
423 if let Some(block) = crate::prompts::load_user_constitution_block() {
424 builder.push(SourceEntry::text(
425 SourceKind::UserConstitution,
426 "User-global constitution",
427 codewhale_config::UserConstitution::path()
428 .ok()
429 .map(|path| path.display().to_string()),
430 ActivationReason::FilePresent,
431 &block,
432 CountingConfidence::High,
433 Some(2),
434 ));
435 }
436
437 let project_context = crate::project_context::load_project_context_with_parents(workspace);
438 if let Some(block) = project_context.constitution_block.as_deref() {
439 builder.push(SourceEntry::text(
440 SourceKind::RepoConstitution,
441 "Repository constitution",
442 project_context
443 .constitution_source_path
444 .as_ref()
445 .map(|path| path.display().to_string()),
446 ActivationReason::FilePresent,
447 block,
448 CountingConfidence::High,
449 Some(4),
450 ));
451 }
452
453 if let Some(content) = project_context.instructions.as_deref() {
454 let source = project_context
455 .source_path
456 .as_ref()
457 .map_or_else(|| "project".to_string(), |p| p.display().to_string());
458 let mut block = format!(
459 "<project_instructions source=\"{source}\">\n{content}\n</project_instructions>"
460 );
461 // Include rules in the report when present
462 if let Some(rules) = &project_context.rules_block {
463 block.push('\n');
464 block.push_str(rules);
465 }
466 builder.push(SourceEntry::text(
467 SourceKind::ProjectContext,
468 "Project instructions",
469 project_context
470 .source_path
471 .as_ref()
472 .map(|path| path.display().to_string()),
473 ActivationReason::FilePresent,
474 &block,
475 CountingConfidence::High,
476 Some(5),
477 ));
478 } else if let Some(rules) = &project_context.rules_block {
479 // Rules exist without main instructions
480 builder.push(SourceEntry::text(
481 SourceKind::ProjectContext,
482 "Project rules",
483 None::<String>,
484 ActivationReason::FilePresent,
485 rules,
486 CountingConfidence::High,
487 Some(5),
488 ));
489 }
490
491 if project_context.constitution_block.is_none() && project_context.instructions.is_none() {
492 builder.push(SourceEntry::omitted(
493 SourceKind::ProjectContext,
494 "Project context and repository instructions",
495 Some(workspace.display().to_string()),
496 Some(5),
497 "no project context block available",
498 ));
499 }
500 if !project_context.warnings.is_empty() {
501 let warnings = project_context.warnings.join("\n");
502 let estimated_tokens = estimate_text_tokens_conservative(&warnings);
503 builder.push(SourceEntry::diagnostic(
504 SourceKind::ProjectContextWarning,
505 "Project context warnings",
506 Some(workspace.display().to_string()),
507 ActivationReason::RuntimeState,
508 warnings,
509 estimated_tokens,
510 Some(4),
511 ));
512 }
513
514 if project_pack_enabled {
515 if let Some(pack) = crate::project_context::generate_project_context_pack(workspace) {
516 builder.push(SourceEntry::text(
517 SourceKind::ProjectContextPack,
518 "Project context pack",
519 Some(workspace.display().to_string()),
520 ActivationReason::ConfigEnabled,
521 &pack,
522 CountingConfidence::Approximate,
523 Some(5),
524 ));
525 }
526 } else {
527 builder.push(SourceEntry::omitted(
528 SourceKind::ProjectContextPack,
529 "Project context pack",
530 Some(workspace.display().to_string()),
531 Some(5),
532 "disabled; project_map provides this information on demand",
533 ));
534 }
535
536 let skill_discovery_mode =
537 crate::skills::SkillDiscoveryMode::from_codewhale_only(skills_scan_codewhale_only);
538 let skills_budget = crate::skills::skills_prompt_budget_chars(context_window_tokens);
539 let skills_block = match skills_dir {
540 Some(dir) => crate::skills::render_available_skills_context_for_workspace_and_dir_with_mode_and_plugins(
541 workspace,
542 dir,
543 skill_discovery_mode,
544 locale_tag,
545 plugin_registry,
546 skills_budget,
547 ),
548 None => crate::skills::render_available_skills_context_for_workspace_with_mode_and_plugins(
549 workspace,
550 skill_discovery_mode,
551 locale_tag,
552 plugin_registry,
553 skills_budget,
554 ),
555 };
556 if let Some(block) = skills_block {
557 builder.push(SourceEntry::text(
558 SourceKind::SkillsBlock,
559 "Available skills",
560 skills_dir.map(|path| path.display().to_string()),
561 ActivationReason::FilePresent,
562 &block,
563 CountingConfidence::High,
564 Some(5),
565 ));
566 } else {
567 builder.push(SourceEntry::omitted(
568 SourceKind::SkillsBlock,
569 "Available skills",
570 skills_dir.map(|path| path.display().to_string()),
571 Some(5),
572 "no skills discovered",
573 ));
574 }
575
576 builder.push(SourceEntry::omitted(
577 SourceKind::ContextManagement,
578 format!("{} runtime mode", mode.label()),
579 None,
580 Some(3),
581 "mode enforced by runtime policy and the live tool catalog; no prompt doctrine",
582 ));
583 builder.push(SourceEntry::omitted(
584 SourceKind::CompactionRelayTemplate,
585 "Session relay template",
586 Some("bundled in this codewhale-tui build (COMPACT_TEMPLATE, compiled in)".to_string()),
587 Some(3),
588 "loaded only when /relay is requested; automatic compaction owns its successor brief",
589 ));
590 builder.push(SourceEntry::text(
591 SourceKind::RuntimePolicy,
592 "Core execution discipline",
593 None,
594 ActivationReason::AlwaysOn,
595 CORE_EXECUTION_PROFILE_PROMPT,
596 CountingConfidence::High,
597 Some(3),
598 ));
599 builder.push(SourceEntry::text(
600 SourceKind::AuthorityRecap,
601 "Authority recap",
602 None,
603 ActivationReason::AlwaysOn,
604 crate::prompts::effective_authority_recap(),
605 CountingConfidence::High,
606 Some(1),
607 ));
608 builder.push(SourceEntry::text(
609 SourceKind::EnvironmentBlock,
610 "Runtime environment",
611 Some(workspace.display().to_string()),
612 ActivationReason::AlwaysOn,
613 &crate::prompts::render_environment_block(workspace, locale_tag),
614 CountingConfidence::High,
615 Some(4),
616 ));
617
618 add_handoff_entry(&mut builder, workspace);
619 builder
620 }
621
622 fn add_app_runtime_entries(builder: &mut ReportBuilder, app: &App) {
623 if let Some(memory_block) =
624 crate::native_memory::native_prompt_block(app.use_memory, &app.memory_path, &app.workspace)
625 {
626 builder.push(SourceEntry::text(
627 SourceKind::UserMemory,
628 "User memory",
629 Some(app.memory_path.display().to_string()),
630 ActivationReason::ConfigEnabled,
631 &memory_block,
632 CountingConfidence::High,
633 Some(6),
634 ));
635 } else {
636 builder.push(SourceEntry::omitted(
637 SourceKind::UserMemory,
638 "User memory",
639 Some(app.memory_path.display().to_string()),
640 Some(6),
641 "disabled, missing, or empty",
642 ));
643 }
644
645 if let Some(goal) = app
646 .goal
647 .objective
648 .as_deref()
649 .filter(|goal| !goal.trim().is_empty())
650 {
651 builder.push(SourceEntry::text(
652 SourceKind::SessionGoal,
653 "Session goal",
654 None,
655 ActivationReason::RuntimeState,
656 goal,
657 CountingConfidence::High,
658 Some(6),
659 ));
660 } else {
661 builder.push(SourceEntry::omitted(
662 SourceKind::SessionGoal,
663 "Session goal",
664 None,
665 Some(6),
666 "no active /goal objective",
667 ));
668 }
669
670 if let Some(tools) = app.session.last_tool_catalog.as_ref() {
671 let rendered = serde_json::to_string(tools).unwrap_or_default();
672 builder.push(SourceEntry::text(
673 SourceKind::ToolSchemas,
674 format!("Tool schemas ({} tools)", tools.len()),
675 None,
676 ActivationReason::PerRequest,
677 &rendered,
678 CountingConfidence::Approximate,
679 Some(3),
680 ));
681 } else {
682 builder.push(SourceEntry::omitted(
683 SourceKind::ToolSchemas,
684 "Tool schemas",
685 None,
686 Some(3),
687 "no tool catalog has been sent yet",
688 ));
689 }
690
691 add_message_entries(builder, &app.api_messages);
692 }
693
694 fn add_handoff_entry(builder: &mut ReportBuilder, workspace: &Path) {
695 let primary = workspace.join(crate::prompts::HANDOFF_RELATIVE_PATH);
696 let legacy = workspace.join(".deepseek/handoff.md");
697 let path = if primary.exists() { primary } else { legacy };
698 let Some(raw) = std::fs::read_to_string(&path)
699 .ok()
700 .filter(|raw| !raw.trim().is_empty())
701 else {
702 builder.push(SourceEntry::omitted(
703 SourceKind::HandoffRelay,
704 "Previous session relay",
705 Some(
706 workspace
707 .join(crate::prompts::HANDOFF_RELATIVE_PATH)
708 .display()
709 .to_string(),
710 ),
711 Some(6),
712 "no relay artifact found",
713 ));
714 return;
715 };
716
717 builder.push(SourceEntry::text(
718 SourceKind::HandoffRelay,
719 "Previous session relay",
720 Some(path.display().to_string()),
721 ActivationReason::FilePresent,
722 &raw,
723 CountingConfidence::High,
724 Some(6),
725 ));
726 }
727
728 fn add_message_entries(builder: &mut ReportBuilder, messages: &[Message]) {
729 if messages.is_empty() {
730 builder.push(SourceEntry::omitted(
731 SourceKind::ConversationHistory,
732 "Conversation history",
733 None,
734 None,
735 "no API messages yet",
736 ));
737 return;
738 }
739
740 let latest_user = messages.iter().rposition(|message| message.role == "user");
741 let mut latest_user_tokens = 0usize;
742 let mut conversation_tokens = 0usize;
743 let mut tool_result_tokens = 0usize;
744 let mut tool_result_count = 0usize;
745
746 for (index, message) in messages.iter().enumerate() {
747 for block in &message.content {
748 let tokens = estimate_text_tokens_conservative(&content_block_text(block));
749 match block {
750 ContentBlock::ToolResult { .. }
751 | ContentBlock::ToolSearchToolResult { .. }
752 | ContentBlock::CodeExecutionToolResult { .. } => {
753 tool_result_tokens += tokens;
754 tool_result_count += 1;
755 }
756 ContentBlock::Text { .. } if Some(index) == latest_user => {
757 latest_user_tokens += tokens;
758 }
759 _ => {
760 conversation_tokens += tokens;
761 }
762 }
763 }
764 }
765
766 if latest_user_tokens > 0 {
767 builder.push(SourceEntry::estimate(
768 SourceKind::UserRequest,
769 "Latest user request",
770 None,
771 ActivationReason::PerRequest,
772 latest_user_tokens,
773 CountingConfidence::High,
774 Some(7),
775 ));
776 }
777 if conversation_tokens > 0 {
778 builder.push(SourceEntry::estimate(
779 SourceKind::ConversationHistory,
780 "Conversation history",
781 None,
782 ActivationReason::RuntimeState,
783 conversation_tokens,
784 CountingConfidence::High,
785 None,
786 ));
787 }
788 if tool_result_count > 0 {
789 builder.push(SourceEntry::estimate(
790 SourceKind::ToolResult,
791 format!("Tool results ({tool_result_count})"),
792 None,
793 ActivationReason::RuntimeState,
794 tool_result_tokens,
795 CountingConfidence::High,
796 None,
797 ));
798 }
799 }
800
801 fn content_block_text(block: &ContentBlock) -> String {
802 match block {
803 ContentBlock::Text { text, .. } => text.clone(),
804 ContentBlock::Thinking { thinking, .. } => thinking.clone(),
805 ContentBlock::ToolResult { content, .. } => content.clone(),
806 ContentBlock::ToolSearchToolResult { content, .. }
807 | ContentBlock::CodeExecutionToolResult { content, .. } => content.to_string(),
808 ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
809 input.to_string()
810 }
811 ContentBlock::ImageUrl { image_url } => image_url.url.clone(),
812 }
813 }
814
815 fn pressure_label(percent: Option<f64>) -> &'static str {
816 // Delegate to the unified pressure thresholds so this diagnostic label can't
817 // drift from `context_budget::PressureLevel`. `None` (unknown window) keeps
818 // its own sentinel since a level requires a usage percentage.
819 match percent {
820 Some(value) => PressureLevel::from_usage_percent(value).label(),
821 None => "unknown",
822 }
823 }
824
825 pub fn format_context_report(report: &PromptSourceMap) -> String {
826 let mut out = String::new();
827 let _ = writeln!(out, "Context Source Map");
828 let _ = writeln!(
829 out,
830 "Estimated active context: {} tokens",
831 report.active_context_estimated_tokens
832 );
833 match (report.context_window_tokens, report.budget_used_percent) {
834 (Some(window), Some(percent)) => {
835 let source = report
836 .context_window_source
837 .as_deref()
838 .unwrap_or_else(|| crate::route_runtime::ContextWindowSource::Fallback.label());
839 // An unverified rung is a guess about the window printed on this
840 // same line; it must not claim a fixed 128K default the capability
841 // matrix may not hold. A label from no known rung is no evidence
842 // either, so it reads the same way.
843 let source_label = if crate::route_runtime::ContextWindowSource::from_label(source)
844 .is_some_and(crate::route_runtime::ContextWindowSource::is_verified)
845 {
846 source.to_string()
847 } else {
848 format!(
849 "{source} (unverified — nothing describes this model, so this window is a guess)"
850 )
851 };
852 let _ = writeln!(
853 out,
854 "Window: {window} tokens ({percent:.1}% used, {}; source: {})",
855 pressure_label(Some(percent)),
856 source_label
857 );
858 }
859 _ => {
860 let _ = writeln!(out, "Window: unknown");
861 }
862 }
863 // #5134: the source label says where the window came from but not how to
864 // change it. Name the key here so the report answers the question it
865 // provokes.
866 let _ = writeln!(
867 out,
868 "Change the window: set `context_window` on the active `[providers.<name>]` table in config.toml (docs/CONFIGURATION.md, \"Context length\")."
869 );
870 let _ = writeln!(
871 out,
872 "Source-entry total: {} tokens",
873 report.total_estimated_tokens
874 );
875 let _ = writeln!(
876 out,
877 "Manage standing law: /constitution (status/preview), /constitution repo (repo-local law), /setup report (readiness)."
878 );
879 let _ = writeln!(out);
880 let _ = writeln!(out, "Sources:");
881 for entry in &report.entries {
882 let path = entry
883 .source_path
884 .as_deref()
885 .map(|path| format!(" [{path}]"))
886 .unwrap_or_default();
887 let tier = entry
888 .authority_tier
889 .map(|tier| format!(", tier {tier}"))
890 .unwrap_or_default();
891 let omitted = entry
892 .truncation_reason
893 .as_deref()
894 .map(|reason| format!(" - {reason}"))
895 .unwrap_or_default();
896 let _ = writeln!(
897 out,
898 "- {:?}: {}{} - {} tokens ({:?}{}){}",
899 entry.source_kind,
900 entry.label,
901 path,
902 entry.estimated_tokens,
903 entry.counting_confidence,
904 tier,
905 omitted
906 );
907 }
908 let _ = writeln!(out);
909 let _ = write!(out, "{}", report.note);
910 out
911 }
912
913 pub fn format_context_summary(report: &PromptSourceMap) -> String {
914 let mut entries = report.entries.clone();
915 entries.sort_by_key(|entry| std::cmp::Reverse(entry.estimated_tokens));
916 let top = entries
917 .iter()
918 .take(5)
919 .map(|entry| format!("{} ({})", entry.label, entry.estimated_tokens))
920 .collect::<Vec<_>>()
921 .join(", ");
922
923 let mut out = String::new();
924 let _ = writeln!(out, "Context Summary");
925 let _ = writeln!(
926 out,
927 "Pressure: {}",
928 pressure_label(report.budget_used_percent)
929 );
930 let _ = writeln!(
931 out,
932 "Estimated active context: {} tokens",
933 report.active_context_estimated_tokens
934 );
935 if let Some(percent) = report.budget_used_percent {
936 let _ = writeln!(out, "Budget used: {percent:.1}%");
937 }
938 let _ = write!(out, "Top sources: {top}");
939 out
940 }
941
942 pub fn context_report_json(report: &PromptSourceMap) -> String {
943 serde_json::to_string_pretty(report).unwrap_or_else(|err| {
944 format!("{{\"error\":\"failed to serialize context report: {err}\"}}")
945 })
946 }
947
948 #[must_use]
949 pub fn prompt_context_json(context: &PromptContext) -> String {
950 serde_json::to_string_pretty(context).unwrap_or_else(|error| {
951 format!(r#"{{"error":"failed to serialize prompt context: {error}"}}"#)
952 })
953 }
954
955 #[cfg(test)]
956 mod tests {
957 use super::*;
958 use crate::config::{ApiProvider, Config};
959 use crate::route_runtime::{ContextWindowResolution, ContextWindowSource};
960 use codewhale_config::route::RouteLimits;
961 use codewhale_models::Role;
962 use codewhale_models::Tool;
963 use std::fs;
964 use tempfile::tempdir;
965
966 #[test]
967 fn context_report_json_contains_sources_and_tool_results() {
968 let messages = vec![
969 Message {
970 role: Role::User,
971 content: vec![ContentBlock::Text {
972 text: "read src/lib.rs".to_string(),
973 cache_control: None,
974 }],
975 },
976 Message {
977 role: Role::Assistant,
978 content: vec![ContentBlock::ToolResult {
979 tool_use_id: "call_1".to_string(),
980 content: "large tool output".repeat(40),
981 is_error: None,
982 content_blocks: None,
983 }],
984 },
985 ];
986 let mut builder = ReportBuilder::new();
987 builder.push(SourceEntry::text(
988 SourceKind::Constitution,
989 "Test static",
990 None,
991 ActivationReason::AlwaysOn,
992 "static",
993 CountingConfidence::High,
994 Some(1),
995 ));
996 add_message_entries(&mut builder, &messages);
997 let report = builder.finish(
998 ContextWindowResolution {
999 tokens: 128_000,
1000 source: ContextWindowSource::Fallback,
1001 },
1002 123,
1003 "test",
1004 );
1005 let json = context_report_json(&report);
1006
1007 assert!(json.contains("\"source_kind\": \"tool_result\""));
1008 assert!(json.contains("\"active_context_estimated_tokens\": 123"));
1009 }
1010
1011 #[test]
1012 fn context_report_surfaces_repo_constitution_source_and_warnings() {
1013 let tmp = tempdir().expect("tempdir");
1014 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
1015 fs::create_dir(tmp.path().join(".codewhale")).expect("mkdir .codewhale");
1016 fs::write(
1017 tmp.path().join(".codewhale").join("constitution.json"),
1018 r#"{
1019 "schema_version": 1,
1020 "authority": ["current user request"],
1021 "branch_policy": "v0.8.53 work targets the codex/v0.8.53 integration branch, not main"
1022 }"#,
1023 )
1024 .expect("write constitution");
1025
1026 let report = build_headless_context_report(&Config::default(), tmp.path());
1027 assert!(
1028 report.entries.iter().any(|entry| {
1029 entry.source_kind == SourceKind::RepoConstitution
1030 && entry.source_path.as_deref().is_some_and(|path| {
1031 path.replace('\\', "/")
1032 .ends_with(".codewhale/constitution.json")
1033 })
1034 }),
1035 "repo constitution source should be an explicit source-map entry: {:?}",
1036 report.entries
1037 );
1038 assert!(
1039 report.entries.iter().any(|entry| {
1040 entry.source_kind == SourceKind::ProjectContextWarning
1041 && entry
1042 .truncation_reason
1043 .as_deref()
1044 .is_some_and(|reason| reason.contains("branch_policy appears stale"))
1045 && entry.estimated_tokens > 0
1046 }),
1047 "repo constitution warnings should be explicit source-map entries: {:?}",
1048 report.entries
1049 );
1050
1051 let formatted = format_context_report(&report);
1052 assert!(formatted.contains("Repository constitution"));
1053 assert!(formatted.contains("Project context warnings"));
1054 assert!(formatted.contains("/constitution"));
1055 assert!(formatted.contains("/setup report"));
1056 let json = context_report_json(&report);
1057 assert!(json.contains("\"repo_constitution\""));
1058 assert!(json.contains("branch_policy appears stale"));
1059 }
1060
1061 #[test]
1062 fn headless_context_report_uses_kimi_code_k3_route_context() {
1063 let tmp = tempdir().expect("workspace");
1064 let config = Config {
1065 provider: Some("moonshot".to_string()),
1066 providers: Some(crate::config::ProvidersConfig {
1067 moonshot: crate::config::ProviderConfig {
1068 api_key: Some("test-kimi-key".to_string()),
1069 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
1070 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
1071 ..Default::default()
1072 },
1073 ..Default::default()
1074 }),
1075 ..Default::default()
1076 };
1077
1078 let report = build_headless_context_report(&config, tmp.path());
1079
1080 assert_eq!(report.context_window_tokens, Some(262_144));
1081 assert_eq!(
1082 report.context_window_source.as_deref(),
1083 Some("static Kimi Code safe floor")
1084 );
1085 assert!(context_report_json(&report).contains("\"context_window_tokens\": 262144"));
1086 }
1087
1088 #[test]
1089 fn headless_context_report_honors_kimi_code_k3_context_override() {
1090 let tmp = tempdir().expect("workspace");
1091 let config = Config {
1092 provider: Some("moonshot".to_string()),
1093 providers: Some(crate::config::ProvidersConfig {
1094 moonshot: crate::config::ProviderConfig {
1095 api_key: Some("test-kimi-key".to_string()),
1096 base_url: Some(crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_string()),
1097 model: Some(crate::config::KIMI_CODE_K3_MODEL.to_string()),
1098 context_window: Some(1_048_576),
1099 ..Default::default()
1100 },
1101 ..Default::default()
1102 }),
1103 ..Default::default()
1104 };
1105
1106 let report = build_headless_context_report(&config, tmp.path());
1107
1108 assert_eq!(report.context_window_tokens, Some(1_048_576));
1109 assert_eq!(report.context_window_source.as_deref(), Some("configured"));
1110 }
1111
1112 fn private_deployment_config(context_window: Option<u32>) -> Config {
1113 Config {
1114 provider: Some("custom".to_string()),
1115 providers: Some(crate::config::ProvidersConfig {
1116 custom: std::collections::HashMap::from([(
1117 "custom".to_string(),
1118 crate::config::ProviderConfig {
1119 api_key: Some("test-private-key".to_string()),
1120 base_url: Some("https://private.test/v1".to_string()),
1121 model: Some("private-1m-deployment-v9".to_string()),
1122 context_window,
1123 ..Default::default()
1124 },
1125 )]),
1126 ..Default::default()
1127 }),
1128 ..Default::default()
1129 }
1130 }
1131
1132 /// #5239: a privately deployed id nobody catalogs, with an operator
1133 /// override, is a 1M route in the report — no route-resolution outcome may
1134 /// silently substitute the legacy window.
1135 #[test]
1136 fn headless_context_report_honors_a_private_model_context_override() {
1137 let tmp = tempdir().expect("workspace");
1138
1139 let report =
1140 build_headless_context_report(&private_deployment_config(Some(1_048_576)), tmp.path());
1141
1142 assert_eq!(report.context_window_tokens, Some(1_048_576));
1143 assert_eq!(report.context_window_source.as_deref(), Some("configured"));
1144 }
1145
1146 /// The same id without an override is a guess, and the report must say so
1147 /// against the window it actually used.
1148 #[test]
1149 fn headless_context_report_marks_an_unknown_private_model_unverified() {
1150 let tmp = tempdir().expect("workspace");
1151
1152 let report = build_headless_context_report(&private_deployment_config(None), tmp.path());
1153
1154 assert_eq!(report.context_window_source.as_deref(), Some("fallback"));
1155 let formatted = format_context_report(&report);
1156 assert!(formatted.contains("this window is a guess"), "{formatted}");
1157 assert!(
1158 !formatted.contains("128K"),
1159 "the fallback rung must not assert a window it did not read: {formatted}"
1160 );
1161 }
1162
1163 #[test]
1164 fn context_report_marks_whale_md_ignored_without_loading_body() {
1165 let tmp = tempdir().expect("tempdir");
1166 fs::write(tmp.path().join("WHALE.md"), "SECRET_LEGACY_WHALE_BODY").expect("write whale");
1167
1168 let report = build_headless_context_report(&Config::default(), tmp.path());
1169 assert!(
1170 report.entries.iter().any(|entry| {
1171 entry.source_kind == SourceKind::ProjectContextWarning
1172 && entry
1173 .truncation_reason
1174 .as_deref()
1175 .is_some_and(|reason| reason.contains("WHALE.md is ignored"))
1176 }),
1177 "ignored WHALE.md should be visible as a migration warning: {:?}",
1178 report.entries
1179 );
1180 assert!(
1181 !context_report_json(&report).contains("SECRET_LEGACY_WHALE_BODY"),
1182 "ignored WHALE.md body must not enter context report"
1183 );
1184 }
1185
1186 #[test]
1187 fn app_context_report_omits_legacy_plain_file_memory() {
1188 // The legacy single-file memory path (`~/.deepseek/memory.md` and
1189 // friends) was deleted for v0.9.4: only the native
1190 // `memory/global/MEMORY.md` store injects.
1191 let tmp = tempdir().expect("tempdir");
1192 let memory_path = tmp.path().join("memory.md");
1193 fs::write(&memory_path, "private legacy memory").expect("write memory");
1194 let config: Config = toml::from_str(
1195 r#"
1196 [memory]
1197 enabled = true
1198 "#,
1199 )
1200 .expect("parse config");
1201 let app = App::new(
1202 crate::tui::app::TuiOptions {
1203 screen_mode: crate::tui::app::ScreenMode::Inline,
1204 use_bracketed_paste: false,
1205 memory_path: memory_path.clone(),
1206 notes_path: tmp.path().join("notes.txt"),
1207 mcp_config_path: tmp.path().join("mcp.json"),
1208 use_memory: true,
1209 start_in_agent_mode: true,
1210 ..crate::test_support::test_tui_options(tmp.path())
1211 },
1212 &config,
1213 );
1214
1215 let report = build_context_report(&app);
1216 let memory_entry = report
1217 .entries
1218 .iter()
1219 .find(|entry| entry.source_kind == SourceKind::UserMemory)
1220 .expect("user memory source entry");
1221
1222 assert_eq!(memory_entry.activation_reason, ActivationReason::Omitted);
1223 assert!(!context_report_json(&report).contains("private legacy memory"));
1224 }
1225
1226 #[test]
1227 fn headless_report_counts_project_pack_only_when_configured() {
1228 let tmp = tempdir().expect("tempdir");
1229 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
1230 fs::create_dir(tmp.path().join("src")).expect("mkdir src");
1231 fs::write(tmp.path().join("src/lib.rs"), "pub fn fixture() {}\n").expect("write fixture");
1232
1233 let default_report = build_headless_context_report(&Config::default(), tmp.path());
1234 let default_pack = default_report
1235 .entries
1236 .iter()
1237 .find(|entry| entry.source_kind == SourceKind::ProjectContextPack)
1238 .expect("project pack entry");
1239 assert_eq!(default_pack.activation_reason, ActivationReason::Omitted);
1240 assert_eq!(default_pack.estimated_tokens, 0);
1241 assert_eq!(
1242 default_pack.truncation_reason.as_deref(),
1243 Some("disabled; project_map provides this information on demand")
1244 );
1245
1246 let relay = default_report
1247 .entries
1248 .iter()
1249 .find(|entry| entry.source_kind == SourceKind::CompactionRelayTemplate)
1250 .expect("relay template entry");
1251 assert_eq!(relay.activation_reason, ActivationReason::Omitted);
1252 assert_eq!(relay.estimated_tokens, 0);
1253
1254 let mut configured = Config::default();
1255 configured.context.project_pack = Some(true);
1256 let configured_report = build_headless_context_report(&configured, tmp.path());
1257 let configured_pack = configured_report
1258 .entries
1259 .iter()
1260 .find(|entry| entry.source_kind == SourceKind::ProjectContextPack)
1261 .expect("configured project pack entry");
1262 assert_eq!(
1263 configured_pack.activation_reason,
1264 ActivationReason::ConfigEnabled
1265 );
1266 assert!(
1267 configured_pack.estimated_tokens > 0,
1268 "configured project pack must be counted"
1269 );
1270
1271 let environment = configured_report
1272 .entries
1273 .iter()
1274 .find(|entry| entry.source_kind == SourceKind::EnvironmentBlock)
1275 .expect("runtime environment entry");
1276 assert_eq!(environment.activation_reason, ActivationReason::AlwaysOn);
1277 }
1278
1279 #[test]
1280 fn app_context_report_counts_configured_project_pack_before_first_turn() {
1281 let tmp = tempdir().expect("tempdir");
1282 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
1283 fs::create_dir(tmp.path().join("src")).expect("mkdir src");
1284 fs::write(tmp.path().join("src/lib.rs"), "pub fn fixture() {}\n").expect("write fixture");
1285 let mut config = Config::default();
1286 config.context.project_pack = Some(true);
1287 let app = App::new(
1288 crate::tui::app::TuiOptions {
1289 screen_mode: crate::tui::app::ScreenMode::Inline,
1290 use_bracketed_paste: false,
1291 notes_path: tmp.path().join("notes.txt"),
1292 mcp_config_path: tmp.path().join("mcp.json"),
1293 start_in_agent_mode: true,
1294 ..crate::test_support::test_tui_options(tmp.path())
1295 },
1296 &config,
1297 );
1298
1299 assert!(
1300 app.system_prompt.is_none(),
1301 "fixture must be pre-first-turn"
1302 );
1303 let report = build_context_report(&app);
1304 let project_pack = report
1305 .entries
1306 .iter()
1307 .find(|entry| entry.source_kind == SourceKind::ProjectContextPack)
1308 .expect("project pack entry");
1309 assert_eq!(
1310 project_pack.activation_reason,
1311 ActivationReason::ConfigEnabled
1312 );
1313 assert!(project_pack.estimated_tokens > 0);
1314 }
1315
1316 #[test]
1317 fn headless_context_report_omits_legacy_plain_file_memory() {
1318 let tmp = tempdir().expect("tempdir");
1319 let memory_path = tmp.path().join("memory.md");
1320 fs::write(&memory_path, "private legacy memory").expect("write memory");
1321 let mut config: Config = toml::from_str(
1322 r#"
1323 [memory]
1324 enabled = true
1325 "#,
1326 )
1327 .expect("parse config");
1328 config.memory_path = Some(memory_path.to_string_lossy().into_owned());
1329
1330 let report = build_headless_context_report(&config, tmp.path());
1331 let memory_entry = report
1332 .entries
1333 .iter()
1334 .find(|entry| entry.source_kind == SourceKind::UserMemory)
1335 .expect("user memory source entry");
1336
1337 assert_eq!(memory_entry.activation_reason, ActivationReason::Omitted);
1338 assert!(!context_report_json(&report).contains("private legacy memory"));
1339 }
1340
1341 #[test]
1342 fn format_summary_lists_largest_sources() {
1343 let mut builder = ReportBuilder::new();
1344 builder.push(SourceEntry::estimate(
1345 SourceKind::ToolSchemas,
1346 "Tool schemas",
1347 None,
1348 ActivationReason::PerRequest,
1349 500,
1350 CountingConfidence::Approximate,
1351 Some(3),
1352 ));
1353 builder.push(SourceEntry::estimate(
1354 SourceKind::UserRequest,
1355 "Latest user request",
1356 None,
1357 ActivationReason::PerRequest,
1358 25,
1359 CountingConfidence::High,
1360 Some(7),
1361 ));
1362 let report = builder.finish(
1363 ContextWindowResolution {
1364 tokens: 128_000,
1365 source: ContextWindowSource::Fallback,
1366 },
1367 525,
1368 "test",
1369 );
1370 let summary = format_context_summary(&report);
1371
1372 assert!(summary.contains("Context Summary"));
1373 assert!(summary.contains("Tool schemas (500)"));
1374 }
1375
1376 #[test]
1377 fn finish_reflects_route_context_window_over_model_default() {
1378 // deepseek-v4-pro defaults to a 1M window; a resolved route advertising a
1379 // smaller window must win in the report's context_window_tokens.
1380 let route_window = 128_000u64;
1381 let model_default = codewhale_models::context_window_for_model("deepseek-v4-pro")
1382 .expect("model has a default window");
1383 assert_ne!(
1384 u64::from(model_default),
1385 route_window,
1386 "test fixture must differ from the model default to be meaningful"
1387 );
1388
1389 let limits = RouteLimits {
1390 context_tokens: Some(route_window),
1391 input_tokens: None,
1392 output_tokens: None,
1393 };
1394 let resolved = crate::route_runtime::resolve_context_window(
1395 ApiProvider::Deepseek,
1396 "deepseek-v4-pro",
1397 Some(limits),
1398 None,
1399 None,
1400 );
1401 assert_eq!(resolved.source, ContextWindowSource::Catalog);
1402
1403 let builder = ReportBuilder::new();
1404 let report = builder.finish(resolved, 10_000, "test");
1405
1406 assert_eq!(report.context_window_tokens, Some(route_window as u32));
1407 assert_eq!(report.context_window_source.as_deref(), Some("catalog"));
1408 // Budget percent is computed against the route window, not the default.
1409 let expected = (10_000.0 / route_window as f64) * 100.0;
1410 let actual = report.budget_used_percent.expect("window known");
1411 assert!(
1412 (actual - expected).abs() < 1e-6,
1413 "got {actual}, want {expected}"
1414 );
1415 }
1416
1417 #[test]
1418 fn pressure_label_matches_unified_pressure_levels() {
1419 // Boundaries mirror context_budget::PressureLevel.
1420 assert_eq!(pressure_label(None), "unknown");
1421 assert_eq!(pressure_label(Some(0.0)), "low");
1422 assert_eq!(pressure_label(Some(39.9)), "low");
1423 assert_eq!(pressure_label(Some(40.0)), "moderate");
1424 assert_eq!(pressure_label(Some(74.9)), "moderate");
1425 assert_eq!(pressure_label(Some(75.0)), "high");
1426 assert_eq!(pressure_label(Some(89.9)), "high");
1427 assert_eq!(pressure_label(Some(90.0)), "critical");
1428 assert_eq!(pressure_label(Some(100.0)), "critical");
1429 }
1430
1431 #[test]
1432 fn tool_schema_entry_serializes_like_runtime_catalog() {
1433 let tool = Tool {
1434 tool_type: Some("function".to_string()),
1435 name: "read_file".to_string(),
1436 description: "read a file".to_string(),
1437 input_schema: serde_json::json!({"type": "object"}),
1438 allowed_callers: None,
1439 defer_loading: None,
1440 input_examples: None,
1441 strict: Some(true),
1442 cache_control: None,
1443 };
1444 let rendered = serde_json::to_string(&vec![tool]).expect("serialize tool");
1445
1446 assert!(rendered.contains("read_file"));
1447 }
1448 }
1449
1449 lines RUST