返回 CodeWhale
fleet_roster.rs
根目录 / crates / tui / src / tui / views / fleet_roster.rs
1 //! `/fleet` roster — the barracks view of the saved agent party.
2 //!
3 //! The roster view is the primary `/fleet` face. The first row is the
4 //! **operator** — the Fleet leader (your live session model). When a user
5 //! picks a session model they are picking the operator, and every member
6 //! below is that leader's team. The header names the selected saved Fleet and
7 //! whether it is user-global or folder-scoped, so scope is never ambiguous.
8 //! Below the operator sits the merged [`FleetRoster`] (built-in <
9 //! `[fleet.profiles]` config < `$CODEWHALE_HOME/agents/*.toml` personal <
10 //! `.codewhale/agents/*.toml` project members)
11 //! as a scrollable list with a detail pane for the selected row. The view
12 //! never writes anything; `s` / Enter on a member hands off to the
13 //! `/fleet setup` wizard for authoring and overrides (the operator row is
14 //! display-only — its route changes via `/model` or `/provider`). Switch
15 //! named Fleets with `/fleet fleets`.
16 //!
17 //! NOTE: like `fleet_setup.rs`, the copy below is intentionally English for
18 //! now (#3167 reworks Fleet UI localization); the command entry
19 //! (`CmdFleetDescription`) is already localized.
20
21 use crossterm::event::{KeyCode, KeyEvent};
22 use ratatui::{
23 buffer::Buffer,
24 layout::{Constraint, Direction, Layout, Rect},
25 style::{Modifier, Style},
26 text::{Line, Span},
27 widgets::{Block, Clear, Paragraph, Widget, Wrap},
28 };
29
30 use crate::config::Config;
31 use crate::fleet::profile::AgentProfile;
32 use crate::fleet::roster::{FleetRoster, ProfileOrigin};
33 use crate::fleet::worker_runtime::roster_member_agent_type;
34 use crate::localization::{Locale, MessageId, tr};
35 use crate::palette;
36 use crate::tui::app::App;
37 use crate::tui::menu_style;
38 use crate::tui::views::{
39 ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
40 truncate_view_text,
41 };
42 use crate::worker_profile::{ShellPolicy, WorkerRuntimeProfile};
43
44 /// The live session route — the operator the roster works for. Read once at
45 /// open, the same way [`super::fleet_setup::FleetSetupSnapshot`] snapshots it.
46 #[derive(Debug, Clone)]
47 struct OperatorInfo {
48 provider: String,
49 /// Exact canonical route key, kept separate from the display label so
50 /// capability lookup can use provider-scoped catalog facts.
51 provider_id: String,
52 model: String,
53 reasoning: String,
54 }
55
56 impl OperatorInfo {
57 fn from_app(app: &App) -> Self {
58 let model = if app.auto_model {
59 app.last_effective_model
60 .as_deref()
61 .map(|effective| format!("auto -> {effective}"))
62 .unwrap_or_else(|| "auto".to_string())
63 } else {
64 app.model.clone()
65 };
66 let route_provider = if app.auto_model {
67 app.last_effective_provider.unwrap_or(app.api_provider)
68 } else {
69 app.api_provider
70 };
71 let provider_id = if app.auto_model {
72 app.last_effective_provider_identity
73 .clone()
74 .unwrap_or_else(|| {
75 if route_provider == crate::config::ApiProvider::Custom {
76 app.provider_identity_for_persistence().to_string()
77 } else {
78 route_provider.as_str().to_string()
79 }
80 })
81 } else {
82 app.provider_identity_for_persistence().to_string()
83 };
84 let provider = if route_provider == crate::config::ApiProvider::Custom {
85 provider_id.clone()
86 } else {
87 route_provider.display_name().to_string()
88 };
89 Self {
90 provider,
91 provider_id,
92 model,
93 reasoning: app.reasoning_effort_display_label(),
94 }
95 }
96 }
97
98 /// Which named Fleet (if any) this session is using, and where that selection
99 /// is pinned — user-global vs this folder only.
100 #[derive(Debug, Clone)]
101 struct SelectedFleetSummary {
102 name: String,
103 scope: crate::fleet::store::FleetScope,
104 }
105
106 pub struct FleetRosterView {
107 operator: OperatorInfo,
108 members: Vec<AgentProfile>,
109 /// Shadow records from the roster load (#5098): which lower-precedence
110 /// files the displayed members are ignoring.
111 shadowed: Vec<crate::fleet::roster::ShadowedProfile>,
112 /// Selected named Fleet + scope, when one is active for this session.
113 selected_fleet: Option<SelectedFleetSummary>,
114 /// Selected row: 0 is the pinned operator row, members follow at 1..
115 selected: usize,
116 detail_scroll: usize,
117 /// UI locale captured from the app at construction (#4057 wave 2).
118 locale: Locale,
119 }
120
121 impl FleetRosterView {
122 #[must_use]
123 pub fn new(app: &App, config: &Config) -> Self {
124 let selected_fleet =
125 crate::fleet::store::selected_fleet(&app.workspace).map(|sel| SelectedFleetSummary {
126 name: sel.name,
127 scope: sel.scope,
128 });
129 let mut view = Self::from_parts(
130 OperatorInfo::from_app(app),
131 FleetRoster::load(&config.fleet_config(), &app.workspace),
132 selected_fleet,
133 );
134 view.locale = app.ui_locale;
135 view
136 }
137
138 fn from_parts(
139 operator: OperatorInfo,
140 roster: FleetRoster,
141 selected_fleet: Option<SelectedFleetSummary>,
142 ) -> Self {
143 Self {
144 operator,
145 // The operator is pinned as its own row 0 (the live session route),
146 // so exclude the built-in "operator" profile from the dispatchable
147 // member list to avoid rendering it twice (#dogfood 0.8.67). The
148 // engine's FleetRoster is untouched, so role/dispatch semantics are
149 // unchanged; only this view drops the duplicate.
150 members: roster
151 .members()
152 .iter()
153 .filter(|m| !m.id.trim().eq_ignore_ascii_case("operator"))
154 .cloned()
155 .collect(),
156 shadowed: roster.shadowed().to_vec(),
157 selected_fleet,
158 selected: 0,
159 detail_scroll: 0,
160 locale: Locale::En,
161 }
162 }
163
164 /// Total selectable rows: the operator plus every roster member.
165 fn row_count(&self) -> usize {
166 1 + self.members.len()
167 }
168
169 fn operator_selected(&self) -> bool {
170 self.selected == 0
171 }
172
173 fn selected_member(&self) -> Option<&AgentProfile> {
174 self.selected.checked_sub(1).and_then(|idx| {
175 self.members
176 .get(idx.min(self.members.len().saturating_sub(1)))
177 })
178 }
179
180 fn move_up(&mut self) {
181 self.selected = crate::tui::list_nav::wrap_index(self.selected, self.row_count(), -1);
182 self.detail_scroll = 0;
183 }
184
185 fn move_down(&mut self) {
186 self.selected = crate::tui::list_nav::wrap_index(self.selected, self.row_count(), 1);
187 self.detail_scroll = 0;
188 }
189
190 fn footer_hints(&self) -> Vec<ActionHint> {
191 vec![
192 ActionHint::new("↑/↓", "move"),
193 ActionHint::new("s/Enter", "setup"),
194 ActionHint::new("f", "saved Fleets"),
195 ActionHint::new("w", tr(self.locale, MessageId::FleetRosterWorkers)),
196 ActionHint::new("PgUp/PgDn", "scroll detail"),
197 ActionHint::new("Esc", "close"),
198 ]
199 }
200 }
201
202 impl ModalView for FleetRosterView {
203 fn kind(&self) -> ModalKind {
204 ModalKind::FleetRoster
205 }
206
207 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
208 self
209 }
210
211 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
212 match key.code {
213 KeyCode::Esc | KeyCode::Char('q') => ViewAction::Close,
214 KeyCode::Up | KeyCode::Char('k') => {
215 self.move_up();
216 ViewAction::None
217 }
218 KeyCode::Down | KeyCode::Char('j') => {
219 self.move_down();
220 ViewAction::None
221 }
222 KeyCode::Enter | KeyCode::Char('s') => {
223 if let Some(member) = self.selected_member() {
224 let role = member.profile.role.name.clone();
225 // Carry the role the operator already chose. The setup
226 // wizard can still step back to Role when they want to
227 // change it, but does not force a duplicate selection.
228 ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenSetupRequested { role })
229 } else {
230 // The operator is not a wizard-authored profile; its
231 // route changes via /model or /provider (the detail pane
232 // says so).
233 ViewAction::None
234 }
235 }
236 KeyCode::Char('w') => {
237 ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenWorkersRequested)
238 }
239 KeyCode::Char('f') => {
240 ViewAction::EmitAndClose(ViewEvent::FleetRosterOpenFleetsRequested)
241 }
242 KeyCode::Home => {
243 self.detail_scroll = 0;
244 ViewAction::None
245 }
246 KeyCode::PageUp => {
247 self.detail_scroll = self.detail_scroll.saturating_sub(8);
248 ViewAction::None
249 }
250 KeyCode::PageDown => {
251 self.detail_scroll = self.detail_scroll.saturating_add(8);
252 ViewAction::None
253 }
254 _ => ViewAction::None,
255 }
256 }
257
258 fn render(&self, area: Rect, buf: &mut Buffer) {
259 Clear.render(area, buf);
260 Block::default()
261 .style(Style::default().bg(palette::WHALE_BG))
262 .render(area, buf);
263
264 let hints = self.footer_hints();
265 let content = render_modal_footer(area, buf, &hints);
266
267 // Hairline shell shared with the HTML route/config/Fleet surfaces.
268 // This replaces the centered legacy card: Fleet is a product room,
269 // not a popup floating over an unrelated transcript.
270 let chunks = Layout::default()
271 .direction(Direction::Vertical)
272 .constraints([Constraint::Length(3), Constraint::Min(1)])
273 .split(content);
274 let header = vec![
275 Line::from(vec![
276 Span::styled(
277 format!("─ {} ", tr(self.locale, MessageId::FleetRosterHeaderLabel)),
278 Style::default().fg(palette::WHALE_ACTION).bold(),
279 ),
280 Span::styled(
281 "──────────────────────── ",
282 Style::default().fg(palette::BORDER_COLOR),
283 ),
284 Span::styled(
285 tr(self.locale, MessageId::FleetRosterTabRoster),
286 Style::default().fg(palette::WHALE_INFO).bold(),
287 ),
288 Span::styled(
289 format!(
290 " {} {} ",
291 tr(self.locale, MessageId::FleetRosterTabSetup),
292 tr(self.locale, MessageId::FleetRosterWorkers)
293 ),
294 Style::default().fg(palette::TEXT_MUTED),
295 ),
296 Span::styled("─".repeat(24), Style::default().fg(palette::BORDER_COLOR)),
297 ]),
298 Line::from(""),
299 Line::from(vec![
300 Span::styled(
301 format!(" {}", self.selected_fleet_line()),
302 Style::default().fg(palette::TEXT_SECONDARY),
303 ),
304 Span::styled(
305 format!(
306 " · {}",
307 tr(self.locale, MessageId::FleetRosterMembersCount)
308 .replace("{count}", &(self.members.len() + 1).to_string())
309 ),
310 Style::default().fg(palette::TEXT_MUTED),
311 ),
312 Span::styled(
313 format!(
314 " · {}",
315 tr(self.locale, MessageId::FleetRosterOperatorFirst)
316 ),
317 Style::default().fg(palette::TEXT_MUTED),
318 ),
319 ]),
320 ];
321 Paragraph::new(header)
322 .wrap(Wrap { trim: false })
323 .render(chunks[0], buf);
324
325 self.render_body(chunks[1], buf);
326 }
327 }
328
329 impl FleetRosterView {
330 /// Scope-explicit selected Fleet line. Paths stay out — receipts name them.
331 fn selected_fleet_line(&self) -> String {
332 match &self.selected_fleet {
333 Some(sel) => format!("Fleet `{}` · {}", sel.name, sel.scope.long_label()),
334 None => "no saved Fleet selected · legacy roster".to_string(),
335 }
336 }
337
338 fn render_body(&self, area: Rect, buf: &mut Buffer) {
339 if area.width == 0 || area.height == 0 {
340 return;
341 }
342
343 // Two columns when there is room, stacked otherwise — same responsive
344 // shape as the setup wizard's choice step so nothing truncates at
345 // 80x24.
346 let (list_area, detail_area) = if area.width >= 56 {
347 let cols = Layout::default()
348 .direction(Direction::Horizontal)
349 .constraints([
350 Constraint::Percentage(45),
351 Constraint::Length(2),
352 Constraint::Min(20),
353 ])
354 .split(area);
355 (cols[0], cols[2])
356 } else {
357 let list_height =
358 (self.row_count() as u16 + 1).min(area.height.saturating_sub(1).max(1));
359 let rows = Layout::default()
360 .direction(Direction::Vertical)
361 .constraints([Constraint::Length(list_height), Constraint::Min(1)])
362 .split(area);
363 (rows[0], rows[1])
364 };
365
366 // Row list: the pinned operator first, then one row per member,
367 // scrolled so the selection stays visible when the party outgrows
368 // the pane.
369 let visible_rows = usize::from(list_area.height).max(1);
370 let first = self
371 .selected
372 .saturating_sub(visible_rows.saturating_sub(1))
373 .min(
374 self.row_count()
375 .saturating_sub(visible_rows.min(self.row_count())),
376 );
377 let list_width = usize::from(list_area.width);
378 let mut list_lines: Vec<Line> = Vec::with_capacity(visible_rows);
379 for idx in first..(first + visible_rows).min(self.row_count()) {
380 let is_selected = idx == self.selected;
381 let pointer = format!("{} ", crate::tui::glyphs::selection_marker(is_selected));
382 let (text, base_style) = if idx == 0 {
383 (
384 format!(
385 "{pointer}@ {} {}",
386 tr(self.locale, MessageId::FleetRosterOperatorRow),
387 self.operator.model
388 ),
389 Style::default()
390 .fg(palette::WHALE_ACTION)
391 .add_modifier(Modifier::BOLD),
392 )
393 } else {
394 let member = &self.members[idx - 1];
395 let mark = member_role_mark(member);
396 // #5098: badge rows whose winning layer is ignoring a
397 // lower-precedence file, so a shadowed personal/project edit
398 // is visible from the list, not just the detail pane.
399 let shadow_badge = if self
400 .shadowed
401 .iter()
402 .any(|shadow| shadow.id.trim().eq_ignore_ascii_case(member.id.trim()))
403 {
404 " ⚠shadows"
405 } else {
406 ""
407 };
408 (
409 format!(
410 "{pointer}{mark} {} {}{}",
411 member.id,
412 member_routing(member),
413 shadow_badge
414 ),
415 Style::default().fg(palette::TEXT_PRIMARY),
416 )
417 };
418 let style = if is_selected {
419 menu_style::selected_row_style()
420 } else {
421 base_style
422 };
423 list_lines.push(Line::from(Span::styled(
424 truncate_view_text(&text, list_width),
425 style,
426 )));
427 }
428 Paragraph::new(list_lines).render(list_area, buf);
429
430 // Detail pane for the selected row.
431 let lines = if self.operator_selected() {
432 operator_detail_lines(&self.operator)
433 } else if let Some(member) = self.selected_member() {
434 // Session model is the operator route so "fast" loadouts resolve
435 // to the fast sibling the runtime will actually launch.
436 member_detail_lines_with_session(
437 member,
438 Some(self.operator.model.as_str()),
439 &self.shadowed,
440 )
441 } else {
442 vec![Line::from(Span::styled(
443 "Roster is empty.",
444 Style::default().fg(palette::TEXT_MUTED),
445 ))]
446 };
447
448 // Same wrapped-row scroll bound as the setup review step: count
449 // visual rows so the tail stays reachable.
450 let wrap_width = usize::from(detail_area.width).max(1);
451 let visual_rows: usize = lines
452 .iter()
453 .map(|line| line.width().div_ceil(wrap_width).max(1))
454 .sum();
455 let max_scroll = visual_rows.saturating_sub(usize::from(detail_area.height).max(1));
456 let scroll = self.detail_scroll.min(max_scroll);
457 Paragraph::new(lines)
458 .wrap(Wrap { trim: true })
459 .scroll((scroll as u16, 0))
460 .render(detail_area, buf);
461 }
462 }
463
464 fn member_role_mark(member: &AgentProfile) -> &'static str {
465 match member.id.as_str() {
466 "manager" | "scout" => crate::tui::glyphs::ROLE_MANAGER,
467 "builder" => crate::tui::glyphs::ROLE_BUILDER,
468 "reviewer" => crate::tui::glyphs::ROLE_REVIEWER,
469 "verifier" => crate::tui::glyphs::ROLE_VERIFIER,
470 "synthesizer" => crate::tui::glyphs::ROLE_SYNTHESIZER,
471 _ => match roster_member_agent_type(member).as_str() {
472 "scout" | "manager" => crate::tui::glyphs::ROLE_MANAGER,
473 "builder" => crate::tui::glyphs::ROLE_BUILDER,
474 "reviewer" => crate::tui::glyphs::ROLE_REVIEWER,
475 "verifier" => crate::tui::glyphs::ROLE_VERIFIER,
476 "synthesizer" => crate::tui::glyphs::ROLE_SYNTHESIZER,
477 _ => crate::tui::glyphs::NEUTRAL,
478 },
479 }
480 }
481
482 /// Shared field renderer for the detail pane.
483 fn detail_field(lines: &mut Vec<Line<'static>>, label: &str, body: String) {
484 lines.push(Line::from(Span::styled(
485 label.to_string(),
486 Style::default().fg(palette::WHALE_INFO).bold(),
487 )));
488 lines.push(Line::from(Span::styled(
489 body,
490 Style::default().fg(palette::TEXT_PRIMARY),
491 )));
492 lines.push(Line::from(""));
493 }
494
495 /// Detail pane for the pinned operator row: the live session route, plus the
496 /// product truth that the operator is this Fleet's leader.
497 fn operator_detail_lines(operator: &OperatorInfo) -> Vec<Line<'static>> {
498 let mut lines: Vec<Line> = Vec::new();
499 detail_field(
500 &mut lines,
501 "Role",
502 "operator · Fleet leader (session route)".to_string(),
503 );
504 detail_field(&mut lines, "Origin", "session".to_string());
505 detail_field(&mut lines, "Posture", "full session authority".to_string());
506 detail_field(&mut lines, "Provider", operator.provider.clone());
507 detail_field(&mut lines, "Model", operator.model.clone());
508 // Session-route capability badges (#5038). Use the exact route key rather
509 // than the display label so built-in routes get provider-scoped catalog
510 // facts; custom routes still fall back conservatively to registry facts.
511 if let Some(badges) = crate::fleet::capability_badges::resolve_route_capability_badges(
512 Some(&operator.provider_id),
513 &operator.model,
514 ) {
515 detail_field(&mut lines, "Capabilities", badges.summary());
516 }
517 detail_field(&mut lines, "Reasoning", operator.reasoning.clone());
518 detail_field(
519 &mut lines,
520 "Description",
521 "The operator is the Fleet leader — your main session model. Every member \
522 below works for this route. It dispatches workers via `agent` profile \
523 spawns and Workflow task({profile}). Change its route with /model or \
524 /provider; persist with /fleet save."
525 .to_string(),
526 );
527 lines
528 }
529
530 /// The resolved worker posture for a roster member: what the runtime would
531 /// actually grant when this member is dispatched (role posture, not the
532 /// profile's requested permissions).
533 fn member_posture(member: &AgentProfile) -> String {
534 let agent_type = roster_member_agent_type(member);
535 let runtime = WorkerRuntimeProfile::for_role(agent_type.clone());
536 let write = if runtime.permissions.write {
537 "write"
538 } else {
539 "read-only"
540 };
541 let shell = match runtime.shell {
542 ShellPolicy::None => "shell none",
543 ShellPolicy::ReadOnly => "shell read-only",
544 ShellPolicy::Full => "shell full",
545 };
546 format!("{} worker · {write} · {shell}", agent_type.as_str())
547 }
548
549 /// The routing truth for a member: explicit model pin, else route preset, else
550 /// same-route inheritance. `[subagents]` overrides still win at dispatch.
551 ///
552 /// When the loadout is `fast`, show that the runtime resolves the **fast
553 /// sibling of the active session model** — not a stale on-disk profile name —
554 /// so the roster matches what Fleet will actually launch.
555 fn member_routing(member: &AgentProfile) -> String {
556 member_routing_with_session(member, None)
557 }
558
559 fn member_routing_with_session(member: &AgentProfile, session_model: Option<&str>) -> String {
560 if let Some(model) = member
561 .profile
562 .model
563 .as_deref()
564 .map(str::trim)
565 .filter(|model| !model.is_empty())
566 {
567 return format!("model {model} (pinned)");
568 }
569 match member.profile.loadout.as_str() {
570 "inherit" => "inherit session route".to_string(),
571 "fast" => match session_model.map(str::trim).filter(|m| !m.is_empty()) {
572 Some(session) => format!("fast sibling of {session} (resolved)"),
573 None => "route preset fast (resolved at launch)".to_string(),
574 },
575 loadout => format!("route preset {loadout}"),
576 }
577 }
578
579 fn member_detail_lines_with_session(
580 member: &AgentProfile,
581 session_model: Option<&str>,
582 shadowed: &[crate::fleet::roster::ShadowedProfile],
583 ) -> Vec<Line<'static>> {
584 let mut lines: Vec<Line> = Vec::new();
585
586 let name = match member.display_name.as_deref().map(str::trim) {
587 Some(display_name) if !display_name.is_empty() && display_name != member.id => {
588 format!("{display_name} ({})", member.id)
589 }
590 _ => member.id.clone(),
591 };
592 detail_field(&mut lines, "Member", name);
593 detail_field(
594 &mut lines,
595 "Origin",
596 match member.origin {
597 ProfileOrigin::BuiltIn => "built-in (default party)".to_string(),
598 _ => format!("{} · {}", member.origin, member.source.display()),
599 },
600 );
601 // #5098: every layer this member is displacing, so a shadowed personal or
602 // project file is visible instead of silently dropped from the merge.
603 for shadow in shadowed
604 .iter()
605 .filter(|shadow| shadow.id.trim().eq_ignore_ascii_case(member.id.trim()))
606 {
607 detail_field(
608 &mut lines,
609 "Shadows",
610 format!(
611 "{} copy at {} (ignored)",
612 shadow.shadowed_origin,
613 shadow.shadowed_source.display()
614 ),
615 );
616 }
617 detail_field(&mut lines, "Slot", member.profile.slot.as_str().to_string());
618 detail_field(&mut lines, "Posture", member_posture(member));
619 detail_field(
620 &mut lines,
621 "Routing",
622 member_routing_with_session(member, session_model),
623 );
624
625 // Capability badges for a pinned model, from the shared Fleet resolver
626 // (#5038). Unknown models omit the field rather than fabricating facts.
627 if let Some(model) = member
628 .profile
629 .model
630 .as_deref()
631 .map(str::trim)
632 .filter(|model| !model.is_empty())
633 && let Some(badges) = crate::fleet::capability_badges::resolve_route_capability_badges(
634 member.profile.provider.as_deref(),
635 model,
636 )
637 {
638 detail_field(&mut lines, "Capabilities", badges.summary());
639 }
640
641 let delegation = &member.profile.delegation;
642 if delegation.max_spawn_depth.is_some() || delegation.max_concurrency.is_some() {
643 let mut bounds: Vec<String> = Vec::new();
644 if let Some(depth) = delegation.max_spawn_depth {
645 bounds.push(format!("spawn depth {depth}"));
646 }
647 if let Some(concurrency) = delegation.max_concurrency {
648 bounds.push(format!("concurrency {concurrency}"));
649 }
650 detail_field(&mut lines, "Delegation", bounds.join(" · "));
651 }
652
653 detail_field(
654 &mut lines,
655 "Instructions",
656 if member.profile.role.instructions.is_some() {
657 match member.origin {
658 ProfileOrigin::Workspace => {
659 format!("custom overlay ({})", member.source.display())
660 }
661 ProfileOrigin::Personal => {
662 format!("personal overlay ({})", member.source.display())
663 }
664 _ => "custom overlay".to_string(),
665 }
666 } else {
667 "none (role posture only)".to_string()
668 },
669 );
670
671 if let Some(description) = member
672 .description
673 .as_deref()
674 .map(str::trim)
675 .filter(|description| !description.is_empty())
676 {
677 detail_field(&mut lines, "Description", description.to_string());
678 }
679
680 lines
681 }
682
683 #[cfg(test)]
684 mod tests;
685
685 lines RUST