| 1 | //! Calm first-run onboarding: one decision per screen. |
| 2 | //! |
| 3 | //! The flow asks only for what Codewhale genuinely needs before the user can |
| 4 | //! begin: language (only when it cannot be confidently inferred from settings |
| 5 | //! or the environment), a provider/model route (only when no usable route is |
| 6 | //! configured), and workspace trust (only when a decision is required). |
| 7 | //! Appearance, command tours, mode primers, and tips stay in `/setup` and |
| 8 | //! contextual help so nothing delays first use. The last screen hands the |
| 9 | //! user the real composer pre-seeded with a first task for this folder. |
| 10 | //! |
| 11 | //! Rendering uses the shared Underwater instrument grammar — one title |
| 12 | //! hairline, one bottom action rail — never a bespoke centered card. |
| 13 | |
| 14 | pub mod language; |
| 15 | pub mod trust_directory; |
| 16 | pub mod welcome; |
| 17 | |
| 18 | use std::path::{Path, PathBuf}; |
| 19 | |
| 20 | use ratatui::{ |
| 21 | Frame, |
| 22 | layout::Rect, |
| 23 | style::{Modifier, Style}, |
| 24 | text::{Line, Span}, |
| 25 | widgets::Paragraph, |
| 26 | }; |
| 27 | |
| 28 | use crate::tui::app::{App, OnboardingState}; |
| 29 | use crate::tui::views::{ActionHint, render_modal_footer, render_underwater_surface}; |
| 30 | use codewhale_localization::MessageId; |
| 31 | use codewhale_palette as palette; |
| 32 | |
| 33 | const ONBOARDED_MARKER_FILE: &str = ".onboarded"; |
| 34 | |
| 35 | /// Cheap workspace markers that identify a code project, so the seeded first |
| 36 | /// task can speak to what is actually in the folder. One `is_file` probe per |
| 37 | /// name; no directory walk. |
| 38 | const CODE_PROJECT_MARKERS: &[&str] = &[ |
| 39 | "Cargo.toml", |
| 40 | "package.json", |
| 41 | "pyproject.toml", |
| 42 | "setup.py", |
| 43 | "go.mod", |
| 44 | "deno.json", |
| 45 | "composer.json", |
| 46 | ]; |
| 47 | |
| 48 | pub fn render(f: &mut Frame, area: Rect, app: &App) { |
| 49 | let title = surface_title(app); |
| 50 | let hints = action_hints(app); |
| 51 | let buf = f.buffer_mut(); |
| 52 | let inner = render_underwater_surface(area, buf, title); |
| 53 | let content = render_modal_footer(inner, buf, &hints); |
| 54 | let lines = screen_lines(app, usize::from(content.width), usize::from(content.height)); |
| 55 | if lines.is_empty() { |
| 56 | return; |
| 57 | } |
| 58 | let body = center_vertically(content, lines.len()); |
| 59 | f.render_widget(Paragraph::new(lines), body); |
| 60 | } |
| 61 | |
| 62 | /// Vertical rest for the short screens: half the leftover rows above, the |
| 63 | /// rest below. Content taller than the area stays top-anchored so nothing is |
| 64 | /// silently pushed out of view. |
| 65 | fn center_vertically(area: Rect, rows: usize) -> Rect { |
| 66 | let pad = (area |
| 67 | .height |
| 68 | .saturating_sub(u16::try_from(rows).unwrap_or(area.height))) |
| 69 | / 2; |
| 70 | Rect { |
| 71 | y: area.y.saturating_add(pad), |
| 72 | height: area.height.saturating_sub(pad), |
| 73 | ..area |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | fn surface_title(app: &App) -> String { |
| 78 | let base = app.tr(MessageId::OnboardStepsTitle).into_owned(); |
| 79 | match required_progress(app) { |
| 80 | Some((current, total)) => format!("{base} · {current}/{total}"), |
| 81 | None => base, |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | /// The hairline surface title counts only the REQUIRED decisions left in |
| 86 | /// this run — never a "Step 1/7" spine. Screens that are not themselves a |
| 87 | /// required decision (welcome, ready) show no counter. A decision drops out |
| 88 | /// of the count once it is satisfied, so the counter only ever moves |
| 89 | /// forward. |
| 90 | fn required_progress(app: &App) -> Option<(usize, usize)> { |
| 91 | // The counter's denominator is every required decision this run, not |
| 92 | // only the ones still ahead: advancing past the language screen must |
| 93 | // not shrink "1 of 2" into a bare "1 of 1". |
| 94 | let mut steps = Vec::new(); |
| 95 | if app.onboarding_had_language_step { |
| 96 | steps.push(OnboardingState::Language); |
| 97 | } |
| 98 | if app.onboarding_had_provider_step { |
| 99 | steps.push(OnboardingState::Provider); |
| 100 | } |
| 101 | if app.onboarding_had_trust_step { |
| 102 | steps.push(OnboardingState::TrustDirectory); |
| 103 | } |
| 104 | if steps.len() < 2 { |
| 105 | return None; |
| 106 | } |
| 107 | let current = steps.iter().position(|step| *step == app.onboarding)?; |
| 108 | Some((current + 1, steps.len())) |
| 109 | } |
| 110 | |
| 111 | fn trust_decision_required(app: &App) -> bool { |
| 112 | !app.trust_mode && needs_trust(&app.workspace) |
| 113 | } |
| 114 | |
| 115 | fn action_hints(app: &App) -> Vec<ActionHint> { |
| 116 | match app.onboarding { |
| 117 | OnboardingState::Welcome => vec![ |
| 118 | ActionHint::new("Enter", app.tr(MessageId::OnboardWelcomeBegin).to_string()), |
| 119 | ActionHint::new("Ctrl+C", app.tr(MessageId::OnboardActionExit).to_string()), |
| 120 | ], |
| 121 | OnboardingState::Language => vec![ |
| 122 | ActionHint::new( |
| 123 | "1-9/a-g", |
| 124 | app.tr(MessageId::OnboardLanguagePick).to_string(), |
| 125 | ), |
| 126 | ActionHint::new("Enter", app.tr(MessageId::OnboardLanguageKeep).to_string()), |
| 127 | ActionHint::new("Esc", app.tr(MessageId::OnboardActionBack).to_string()), |
| 128 | ], |
| 129 | OnboardingState::Provider => vec![ |
| 130 | ActionHint::new( |
| 131 | "Enter", |
| 132 | app.tr(MessageId::OnboardProviderChoose).to_string(), |
| 133 | ), |
| 134 | ActionHint::new( |
| 135 | "Ctrl+O", |
| 136 | app.tr(MessageId::OnboardProviderOffline).to_string(), |
| 137 | ), |
| 138 | ActionHint::new("Esc", app.tr(MessageId::OnboardActionBack).to_string()), |
| 139 | ], |
| 140 | OnboardingState::TrustDirectory => vec![ |
| 141 | ActionHint::new( |
| 142 | "1/Y", |
| 143 | app.tr(MessageId::OnboardTrustActionTrust).to_string(), |
| 144 | ), |
| 145 | ActionHint::new("2/U", app.tr(MessageId::OnboardTrustActionSkip).to_string()), |
| 146 | ActionHint::new("3/N", app.tr(MessageId::OnboardTrustActionQuit).to_string()), |
| 147 | ], |
| 148 | OnboardingState::Ready => vec![ |
| 149 | ActionHint::new("Enter", app.tr(MessageId::OnboardReadyStart).to_string()), |
| 150 | ActionHint::new( |
| 151 | "/rc", |
| 152 | app.tr(MessageId::CmdRemoteControlDescription).to_string(), |
| 153 | ), |
| 154 | ActionHint::new("C", app.tr(MessageId::OnboardReadyCustomize).to_string()), |
| 155 | ], |
| 156 | OnboardingState::None => Vec::new(), |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | fn screen_lines(app: &App, width: usize, height: usize) -> Vec<Line<'static>> { |
| 161 | match app.onboarding { |
| 162 | OnboardingState::Welcome => welcome::lines(app, width), |
| 163 | OnboardingState::Language => language::lines(app, width, height), |
| 164 | OnboardingState::Provider => provider_lines(app, width), |
| 165 | OnboardingState::TrustDirectory => trust_directory::lines(app, width), |
| 166 | OnboardingState::Ready => welcome::ready_lines(app, width), |
| 167 | OnboardingState::None => Vec::new(), |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | fn provider_lines(app: &App, width: usize) -> Vec<Line<'static>> { |
| 172 | let mut lines = Vec::new(); |
| 173 | heading(&mut lines, app, MessageId::OnboardProviderTitle, width); |
| 174 | lines.push(Line::from("")); |
| 175 | wrap_body(&mut lines, app, MessageId::OnboardProviderBlurb, width); |
| 176 | lines |
| 177 | } |
| 178 | |
| 179 | /// Same rule as the welcome headline: a heading is prose and wraps. Today's |
| 180 | /// provider title happens to fit at 40 columns in every shipped locale, but it |
| 181 | /// fit by luck rather than by construction. |
| 182 | fn heading(out: &mut Vec<Line<'static>>, app: &App, id: MessageId, width: usize) { |
| 183 | for segment in wrap_words(&app.tr(id), width) { |
| 184 | out.push(Line::from(Span::styled( |
| 185 | segment, |
| 186 | Style::default() |
| 187 | .fg(palette::WHALE_ACTION) |
| 188 | .add_modifier(Modifier::BOLD), |
| 189 | ))); |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | /// Append a body sentence word-wrapped to `width` in the muted body lane. |
| 194 | fn wrap_body(lines: &mut Vec<Line<'static>>, app: &App, id: MessageId, width: usize) { |
| 195 | let text = app.tr(id); |
| 196 | for segment in wrap_words(&text, width) { |
| 197 | lines.push(Line::from(Span::styled( |
| 198 | segment, |
| 199 | Style::default().fg(palette::TEXT_PRIMARY), |
| 200 | ))); |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | /// Characters that may not begin a line in Japanese and Chinese typography |
| 205 | /// (a small, uncontroversial kinsoku set: closing brackets, sentence-final |
| 206 | /// punctuation, and the sound-extension mark). When a width break would strand |
| 207 | /// one of these at the start of a line, carry its preceding grapheme forward. |
| 208 | const NO_LINE_START: &[char] = &[ |
| 209 | '。', '、', '.', ',', ',', '。', '」', '』', ')', ']', '}', '〕', '〉', '》', '”', '’', |
| 210 | '!', '?', ':', ';', 'ー', '々', '·', '…', '!', '?', ',', '.', ':', ';', ')', ']', '}', |
| 211 | ]; |
| 212 | |
| 213 | /// Break one unbreakable token into lines of at most `width` display columns. |
| 214 | /// |
| 215 | /// Japanese, Chinese, and Thai do not separate words with spaces, so an entire |
| 216 | /// sentence arrives as a single token. Breaking on grapheme clusters by display |
| 217 | /// width is the conventional behaviour for those scripts and is the only way to |
| 218 | /// show the text at all; the alternative is the clip this replaces. |
| 219 | fn break_by_display_width(text: &str, width: usize) -> Vec<String> { |
| 220 | use unicode_segmentation::UnicodeSegmentation; |
| 221 | use unicode_width::UnicodeWidthStr; |
| 222 | |
| 223 | let mut out: Vec<String> = Vec::new(); |
| 224 | let mut current = String::new(); |
| 225 | let mut current_width = 0usize; |
| 226 | |
| 227 | for cluster in text.graphemes(true) { |
| 228 | let cluster_width = UnicodeWidthStr::width(cluster); |
| 229 | if current_width + cluster_width > width && !current.is_empty() { |
| 230 | let starts_forbidden = cluster |
| 231 | .chars() |
| 232 | .next() |
| 233 | .is_some_and(|c| NO_LINE_START.contains(&c)); |
| 234 | if starts_forbidden { |
| 235 | // Carry the preceding text with its punctuation onto the next |
| 236 | // line. Extending a full line instead silently clips it in a |
| 237 | // terminal viewport, including on the redaction consent gate. |
| 238 | if let Some((split, _)) = current.grapheme_indices(true).rev().find(|(_, part)| { |
| 239 | !part |
| 240 | .chars() |
| 241 | .next() |
| 242 | .is_some_and(|ch| NO_LINE_START.contains(&ch)) |
| 243 | }) && split > 0 |
| 244 | && UnicodeWidthStr::width(¤t[split..]) + cluster_width <= width |
| 245 | { |
| 246 | let carry = current.split_off(split); |
| 247 | out.push(std::mem::replace(&mut current, carry)); |
| 248 | current_width = UnicodeWidthStr::width(current.as_str()); |
| 249 | } else { |
| 250 | // A punctuation-only run cannot satisfy both typography |
| 251 | // and width; preserve every character inside the viewport. |
| 252 | out.push(std::mem::take(&mut current)); |
| 253 | current_width = 0; |
| 254 | } |
| 255 | } else { |
| 256 | out.push(std::mem::take(&mut current)); |
| 257 | current_width = 0; |
| 258 | } |
| 259 | } |
| 260 | current.push_str(cluster); |
| 261 | current_width += cluster_width; |
| 262 | } |
| 263 | |
| 264 | if !current.is_empty() { |
| 265 | out.push(current); |
| 266 | } |
| 267 | out |
| 268 | } |
| 269 | |
| 270 | /// Word wrap by display width so the composed row count is exact and no |
| 271 | /// paragraph re-wrap can clip a locale with longer sentences. |
| 272 | pub(crate) fn wrap_words(text: &str, width: usize) -> Vec<String> { |
| 273 | use unicode_width::UnicodeWidthStr; |
| 274 | let width = width.max(8); |
| 275 | let mut out = Vec::new(); |
| 276 | let mut current = String::new(); |
| 277 | let mut current_width = 0usize; |
| 278 | for word in text.split_whitespace() { |
| 279 | let word_width = UnicodeWidthStr::width(word); |
| 280 | |
| 281 | // A token wider than the whole lane cannot fit on any line. Scripts |
| 282 | // that do not delimit words produce exactly one such token per |
| 283 | // sentence, and the word-only path below never breaks it — it appended |
| 284 | // the token whole and the terminal clipped the tail, silently dropping |
| 285 | // the second half of every long Japanese string. |
| 286 | if word_width > width { |
| 287 | if !current.is_empty() { |
| 288 | out.push(std::mem::take(&mut current)); |
| 289 | current_width = 0; |
| 290 | } |
| 291 | let mut chunks = break_by_display_width(word, width); |
| 292 | if let Some(last) = chunks.pop() { |
| 293 | out.extend(chunks); |
| 294 | current_width = UnicodeWidthStr::width(last.as_str()); |
| 295 | current = last; |
| 296 | } |
| 297 | continue; |
| 298 | } |
| 299 | |
| 300 | let needed = if current.is_empty() { |
| 301 | word_width |
| 302 | } else { |
| 303 | current_width + 1 + word_width |
| 304 | }; |
| 305 | if !current.is_empty() && needed > width { |
| 306 | out.push(std::mem::take(&mut current)); |
| 307 | current_width = 0; |
| 308 | } |
| 309 | if !current.is_empty() { |
| 310 | current.push(' '); |
| 311 | current_width += 1; |
| 312 | } |
| 313 | current.push_str(word); |
| 314 | current_width += word_width; |
| 315 | } |
| 316 | if !current.is_empty() { |
| 317 | out.push(current); |
| 318 | } |
| 319 | if out.is_empty() { |
| 320 | out.push(String::new()); |
| 321 | } |
| 322 | out |
| 323 | } |
| 324 | |
| 325 | pub fn default_marker_path() -> Option<PathBuf> { |
| 326 | let primary_home = codewhale_config::codewhale_home().ok()?; |
| 327 | let legacy_home = if codewhale_config::codewhale_home_is_explicit() { |
| 328 | None |
| 329 | } else { |
| 330 | codewhale_config::legacy_deepseek_home().ok() |
| 331 | }; |
| 332 | Some(marker_path_with_roots( |
| 333 | &primary_home, |
| 334 | legacy_home.as_deref(), |
| 335 | )) |
| 336 | } |
| 337 | |
| 338 | #[cfg(test)] |
| 339 | fn marker_path_with_home(home: &Path) -> PathBuf { |
| 340 | marker_path_with_roots( |
| 341 | &home.join(".codewhale"), |
| 342 | Some(home.join(".deepseek").as_path()), |
| 343 | ) |
| 344 | } |
| 345 | |
| 346 | fn marker_path_with_roots(primary_home: &Path, legacy_home: Option<&Path>) -> PathBuf { |
| 347 | let primary = primary_home.join(ONBOARDED_MARKER_FILE); |
| 348 | if primary.exists() { |
| 349 | return primary; |
| 350 | } |
| 351 | if let Some(legacy_home) = legacy_home { |
| 352 | let legacy = legacy_home.join(ONBOARDED_MARKER_FILE); |
| 353 | if legacy.exists() { |
| 354 | return legacy; |
| 355 | } |
| 356 | } |
| 357 | primary |
| 358 | } |
| 359 | |
| 360 | pub fn is_onboarded() -> bool { |
| 361 | default_marker_path().is_some_and(|path| path.exists()) |
| 362 | } |
| 363 | |
| 364 | pub fn mark_onboarded() -> std::io::Result<PathBuf> { |
| 365 | let path = default_marker_path().ok_or_else(|| { |
| 366 | std::io::Error::new( |
| 367 | std::io::ErrorKind::NotFound, |
| 368 | "Codewhale home directory not found", |
| 369 | ) |
| 370 | })?; |
| 371 | mark_onboarded_at_path(path) |
| 372 | } |
| 373 | |
| 374 | #[cfg(test)] |
| 375 | fn mark_onboarded_at_home(home: &Path) -> std::io::Result<PathBuf> { |
| 376 | let path = marker_path_with_home(home); |
| 377 | mark_onboarded_at_path(path) |
| 378 | } |
| 379 | |
| 380 | fn mark_onboarded_at_path(path: PathBuf) -> std::io::Result<PathBuf> { |
| 381 | if let Some(parent) = path.parent() { |
| 382 | std::fs::create_dir_all(parent)?; |
| 383 | } |
| 384 | std::fs::write(&path, "")?; |
| 385 | Ok(path) |
| 386 | } |
| 387 | |
| 388 | pub fn needs_trust(workspace: &Path) -> bool { |
| 389 | if crate::config::is_workspace_trusted(workspace) { |
| 390 | return false; |
| 391 | } |
| 392 | |
| 393 | let markers = [ |
| 394 | workspace.join(".deepseek").join("trusted"), |
| 395 | workspace.join(".deepseek").join("trust.json"), |
| 396 | ]; |
| 397 | !markers.iter().any(|path| path.exists()) |
| 398 | } |
| 399 | |
| 400 | pub fn mark_trusted(workspace: &Path) -> anyhow::Result<PathBuf> { |
| 401 | crate::config::save_workspace_trust(workspace) |
| 402 | } |
| 403 | |
| 404 | /// Whether the UI locale can be trusted without asking. An explicit settings |
| 405 | /// value or an environment locale that resolves to a shipped pack is |
| 406 | /// confident; anything else defaults to English silently, so first run asks |
| 407 | /// once. Returning users never see the language screen. |
| 408 | pub fn locale_confidently_inferred(setting: &str) -> bool { |
| 409 | let normalized = codewhale_localization::normalize_configured_locale(setting); |
| 410 | if normalized.is_some_and(|tag| tag != "auto") { |
| 411 | return true; |
| 412 | } |
| 413 | ["LC_ALL", "LC_MESSAGES", "LANG"].iter().any(|key| { |
| 414 | std::env::var(key) |
| 415 | .ok() |
| 416 | .filter(|value| locale_var_names_a_language(value)) |
| 417 | .and_then(|value| codewhale_localization::normalize_configured_locale(&value)) |
| 418 | .is_some_and(|tag| tag != "auto") |
| 419 | }) |
| 420 | } |
| 421 | |
| 422 | /// A POSIX/C locale names an encoding, not a language: `C` and `C.UTF-8` |
| 423 | /// pass through `normalize_configured_locale` as concrete tags, so the |
| 424 | /// inference gate must reject them explicitly or a stock terminal |
| 425 | /// environment reads as a confident language pick. |
| 426 | fn locale_var_names_a_language(value: &str) -> bool { |
| 427 | let language = value.split(['.', '_', '@']).next().unwrap_or_default(); |
| 428 | !matches!(language, "" | "C" | "POSIX" | "c" | "posix") |
| 429 | } |
| 430 | |
| 431 | /// The example task the ready screen seeds into the composer, chosen from |
| 432 | /// what is cheaply visible in the workspace. |
| 433 | pub fn first_task_seed(workspace: &Path, locale: codewhale_localization::Locale) -> String { |
| 434 | let id = if CODE_PROJECT_MARKERS |
| 435 | .iter() |
| 436 | .any(|marker| workspace.join(marker).is_file()) |
| 437 | { |
| 438 | MessageId::OnboardSeedCodeProject |
| 439 | } else { |
| 440 | MessageId::OnboardSeedFolder |
| 441 | }; |
| 442 | codewhale_localization::tr(locale, id).into_owned() |
| 443 | } |
| 444 | |
| 445 | /// Welcome → the first decision this run actually needs. |
| 446 | pub fn advance_onboarding_from_welcome(app: &mut App) { |
| 447 | app.status_message = None; |
| 448 | app.onboarding = if app.onboarding_had_language_step { |
| 449 | OnboardingState::Language |
| 450 | } else if app.onboarding_needs_api_key { |
| 451 | OnboardingState::Provider |
| 452 | } else if trust_decision_required(app) { |
| 453 | OnboardingState::TrustDirectory |
| 454 | } else { |
| 455 | OnboardingState::Ready |
| 456 | }; |
| 457 | } |
| 458 | |
| 459 | /// Language → the next decision; the language step never repeats. |
| 460 | pub fn advance_onboarding_after_language(app: &mut App) { |
| 461 | app.status_message = None; |
| 462 | app.onboarding = if app.onboarding_needs_api_key { |
| 463 | OnboardingState::Provider |
| 464 | } else if trust_decision_required(app) { |
| 465 | OnboardingState::TrustDirectory |
| 466 | } else { |
| 467 | OnboardingState::Ready |
| 468 | }; |
| 469 | } |
| 470 | |
| 471 | /// Provider setup → trust when a decision is required, otherwise the ready |
| 472 | /// screen. |
| 473 | pub fn advance_onboarding_after_provider(app: &mut App) { |
| 474 | app.status_message = None; |
| 475 | if trust_decision_required(app) { |
| 476 | app.onboarding = OnboardingState::TrustDirectory; |
| 477 | } else { |
| 478 | app.onboarding = OnboardingState::Ready; |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | /// Take the explicit "explore offline" exit advertised by Provider setup |
| 483 | /// (#3927). |
| 484 | /// |
| 485 | /// The contract this encodes, in full: |
| 486 | /// |
| 487 | /// * **No provider is selected and no route is activated.** This function must |
| 488 | /// never reach `switch_provider`, never persist `provider`, and never write a |
| 489 | /// credential. Callers pass only `&mut App`, which makes that structural. |
| 490 | /// * **No draft secret is owned by `App`.** The caller closes the canonical |
| 491 | /// picker before entering this transition, dropping its private draft. |
| 492 | /// * **`onboarding_needs_api_key` stays true**, because nothing was supplied. |
| 493 | /// The launch surface, `/setup`, and doctor keep telling the truth. |
| 494 | /// * **The remaining required decisions still run** — trust, then the ready |
| 495 | /// screen — so browsing offline is a complete first run and not an early |
| 496 | /// exit. |
| 497 | /// * Queue semantics are inherited from `offline_mode`, untouched here. |
| 498 | pub fn choose_offline_explore(app: &mut App) { |
| 499 | app.api_key_env_only = false; |
| 500 | app.onboarding_needs_api_key = true; |
| 501 | app.onboarding_explore_offline = true; |
| 502 | app.offline_mode = true; |
| 503 | // `advance_*` clears the status bar, so the label is applied after it. |
| 504 | advance_onboarding_after_provider(app); |
| 505 | app.status_message = Some( |
| 506 | app.tr(codewhale_localization::MessageId::OnboardOfflineNotice) |
| 507 | .into_owned(), |
| 508 | ); |
| 509 | app.needs_redraw = true; |
| 510 | } |
| 511 | |
| 512 | /// Clear the offline-explore label once a real route is activated (#3927). |
| 513 | /// |
| 514 | /// This is the *only* thing that retires the label: it is not time-based and |
| 515 | /// not cleared by dismissing a screen. |
| 516 | pub fn clear_offline_explore_on_route_activation(app: &mut App) { |
| 517 | app.onboarding_explore_offline = false; |
| 518 | } |
| 519 | |
| 520 | /// Finish first run from the ready screen and land in the real composer, |
| 521 | /// pre-seeded with a useful first task for this folder. Enter opens the |
| 522 | /// product; it never opens another educational surface. |
| 523 | pub fn finish_ready_and_open_composer(app: &mut App) { |
| 524 | app.finish_onboarding_without_feature_intro(); |
| 525 | if app.composer.input.trim().is_empty() { |
| 526 | let seed = first_task_seed(&app.workspace, app.ui_locale); |
| 527 | app.composer.input = seed; |
| 528 | app.composer.cursor_position = app.composer.input.chars().count(); |
| 529 | } |
| 530 | app.needs_redraw = true; |
| 531 | } |
| 532 | |
| 533 | #[cfg(test)] |
| 534 | mod tests { |
| 535 | use super::*; |
| 536 | use crate::config::Config; |
| 537 | use crate::tui::app::{App, TuiOptions}; |
| 538 | use codewhale_localization::{Locale, MessageId, tr}; |
| 539 | use std::path::PathBuf; |
| 540 | |
| 541 | /// A first-run app with the onboarding decision reset to "nothing asked |
| 542 | /// yet". `App::new` derives that decision from the ambient machine — |
| 543 | /// inferable locale, an existing `settings.toml`, a provider key in the |
| 544 | /// environment — so a fixture that overrides only the flags it names |
| 545 | /// inherits the rest of the developer's box and asserts something |
| 546 | /// different in CI. Every test below opts in to the steps it is about. |
| 547 | fn test_app_with_locale(locale: Locale) -> App { |
| 548 | let options = TuiOptions { |
| 549 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 550 | }; |
| 551 | let mut app = App::new(options, &Config::default()); |
| 552 | app.ui_locale = locale; |
| 553 | app.onboarding_needs_api_key = false; |
| 554 | app.onboarding_missing_key_recovery = false; |
| 555 | app.onboarding_explore_offline = false; |
| 556 | app.onboarding_had_language_step = false; |
| 557 | app.onboarding_had_provider_step = false; |
| 558 | app.onboarding_had_trust_step = false; |
| 559 | app |
| 560 | } |
| 561 | |
| 562 | fn flattened(lines: Vec<Line<'static>>) -> String { |
| 563 | lines |
| 564 | .into_iter() |
| 565 | .flat_map(|line| { |
| 566 | line.spans |
| 567 | .into_iter() |
| 568 | .map(|span| span.content.to_string()) |
| 569 | .collect::<Vec<_>>() |
| 570 | }) |
| 571 | .collect::<Vec<_>>() |
| 572 | .join("\n") |
| 573 | } |
| 574 | |
| 575 | // ── Navigation: one decision per screen, conditional ───────────────── |
| 576 | |
| 577 | #[test] |
| 578 | fn welcome_routes_to_the_first_decision_this_run_needs() { |
| 579 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 580 | |
| 581 | // Everything configured: welcome → ready with zero questions between. |
| 582 | let mut app = test_app_with_locale(Locale::En); |
| 583 | app.workspace = tmp.path().to_path_buf(); |
| 584 | app.trust_mode = true; |
| 585 | app.onboarding_needs_api_key = false; |
| 586 | app.onboarding_had_language_step = false; |
| 587 | advance_onboarding_from_welcome(&mut app); |
| 588 | assert_eq!(app.onboarding, OnboardingState::Ready); |
| 589 | |
| 590 | // No route configured: provider comes first. |
| 591 | let mut app = test_app_with_locale(Locale::En); |
| 592 | app.workspace = tmp.path().to_path_buf(); |
| 593 | app.trust_mode = true; |
| 594 | app.onboarding_needs_api_key = true; |
| 595 | advance_onboarding_from_welcome(&mut app); |
| 596 | assert_eq!(app.onboarding, OnboardingState::Provider); |
| 597 | |
| 598 | // Route present, workspace untrusted: trust is the only decision. |
| 599 | let mut app = test_app_with_locale(Locale::En); |
| 600 | app.workspace = tmp.path().to_path_buf(); |
| 601 | app.trust_mode = false; |
| 602 | app.onboarding_needs_api_key = false; |
| 603 | advance_onboarding_from_welcome(&mut app); |
| 604 | assert_eq!(app.onboarding, OnboardingState::TrustDirectory); |
| 605 | |
| 606 | // Language cannot be inferred: it precedes every other decision. |
| 607 | let mut app = test_app_with_locale(Locale::En); |
| 608 | app.workspace = tmp.path().to_path_buf(); |
| 609 | app.trust_mode = false; |
| 610 | app.onboarding_needs_api_key = true; |
| 611 | app.onboarding_had_language_step = true; |
| 612 | advance_onboarding_from_welcome(&mut app); |
| 613 | assert_eq!(app.onboarding, OnboardingState::Language); |
| 614 | } |
| 615 | |
| 616 | #[test] |
| 617 | fn language_step_never_repeats_and_falls_through_to_ready() { |
| 618 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 619 | let mut app = test_app_with_locale(Locale::En); |
| 620 | app.workspace = tmp.path().to_path_buf(); |
| 621 | app.trust_mode = true; |
| 622 | app.onboarding_had_language_step = true; |
| 623 | app.onboarding_needs_api_key = false; |
| 624 | |
| 625 | advance_onboarding_after_language(&mut app); |
| 626 | assert_eq!(app.onboarding, OnboardingState::Ready); |
| 627 | } |
| 628 | |
| 629 | #[test] |
| 630 | fn provider_step_routes_to_trust_only_when_a_decision_is_required() { |
| 631 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 632 | |
| 633 | let mut app = test_app_with_locale(Locale::En); |
| 634 | app.workspace = tmp.path().to_path_buf(); |
| 635 | app.trust_mode = false; |
| 636 | app.onboarding_missing_key_recovery = false; |
| 637 | advance_onboarding_after_provider(&mut app); |
| 638 | assert_eq!(app.onboarding, OnboardingState::TrustDirectory); |
| 639 | |
| 640 | let mut trusted = test_app_with_locale(Locale::En); |
| 641 | trusted.workspace = tmp.path().to_path_buf(); |
| 642 | trusted.trust_mode = true; |
| 643 | advance_onboarding_after_provider(&mut trusted); |
| 644 | assert_eq!(trusted.onboarding, OnboardingState::Ready); |
| 645 | } |
| 646 | |
| 647 | #[test] |
| 648 | fn missing_key_recovery_ends_on_ready_like_a_first_run() { |
| 649 | let mut app = test_app_with_locale(Locale::En); |
| 650 | app.trust_mode = true; |
| 651 | app.onboarding_missing_key_recovery = true; |
| 652 | advance_onboarding_after_provider(&mut app); |
| 653 | assert_eq!(app.onboarding, OnboardingState::Ready); |
| 654 | } |
| 655 | |
| 656 | #[test] |
| 657 | fn explore_offline_still_traverses_trust_then_ready() { |
| 658 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 659 | let mut app = test_app_with_locale(Locale::En); |
| 660 | app.onboarding = OnboardingState::Provider; |
| 661 | app.trust_mode = false; |
| 662 | app.workspace = tmp.path().to_path_buf(); |
| 663 | |
| 664 | choose_offline_explore(&mut app); |
| 665 | assert_eq!(app.onboarding, OnboardingState::TrustDirectory); |
| 666 | assert!(app.onboarding_explore_offline); |
| 667 | |
| 668 | // A trusted workspace skips only the trust screen, never the ending. |
| 669 | let mut trusted = test_app_with_locale(Locale::En); |
| 670 | trusted.onboarding = OnboardingState::Provider; |
| 671 | trusted.trust_mode = true; |
| 672 | choose_offline_explore(&mut trusted); |
| 673 | assert_eq!(trusted.onboarding, OnboardingState::Ready); |
| 674 | } |
| 675 | |
| 676 | #[test] |
| 677 | fn offline_explore_selects_no_provider_and_writes_no_credential() { |
| 678 | let mut app = test_app_with_locale(Locale::En); |
| 679 | app.onboarding = OnboardingState::Provider; |
| 680 | app.onboarding_needs_api_key = true; |
| 681 | app.trust_mode = true; |
| 682 | let provider_before = app.api_provider; |
| 683 | let model_before = app.model.clone(); |
| 684 | |
| 685 | choose_offline_explore(&mut app); |
| 686 | |
| 687 | assert_eq!(app.api_provider, provider_before); |
| 688 | assert_eq!(app.model, model_before); |
| 689 | assert!(!app.api_key_env_only); |
| 690 | assert!(app.onboarding_needs_api_key); |
| 691 | assert!(app.onboarding_explore_offline); |
| 692 | assert!(app.offline_mode); |
| 693 | } |
| 694 | |
| 695 | #[test] |
| 696 | fn offline_label_only_clears_when_a_route_is_activated() { |
| 697 | let mut app = test_app_with_locale(Locale::En); |
| 698 | app.trust_mode = true; |
| 699 | choose_offline_explore(&mut app); |
| 700 | assert!(app.onboarding_explore_offline); |
| 701 | |
| 702 | clear_offline_explore_on_route_activation(&mut app); |
| 703 | assert!(!app.onboarding_explore_offline); |
| 704 | } |
| 705 | |
| 706 | // ── Conditional required steps: the counter ────────────────────────── |
| 707 | |
| 708 | #[test] |
| 709 | fn progress_counts_only_required_decisions() { |
| 710 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 711 | |
| 712 | // One required decision → no counter at all. |
| 713 | let mut app = test_app_with_locale(Locale::En); |
| 714 | app.workspace = tmp.path().to_path_buf(); |
| 715 | app.trust_mode = true; |
| 716 | app.onboarding_needs_api_key = true; |
| 717 | app.onboarding = OnboardingState::Provider; |
| 718 | assert_eq!(required_progress(&app), None); |
| 719 | |
| 720 | // Language + provider: the language screen is 1 of 2. |
| 721 | app.onboarding_had_language_step = true; |
| 722 | app.onboarding_had_provider_step = true; |
| 723 | app.onboarding = OnboardingState::Language; |
| 724 | assert_eq!(required_progress(&app), Some((1, 2))); |
| 725 | app.onboarding = OnboardingState::Provider; |
| 726 | assert_eq!(required_progress(&app), Some((2, 2))); |
| 727 | |
| 728 | // Completing provider setup changes live route state, but never |
| 729 | // rewrites the receipt-backed denominator for this run. |
| 730 | app.onboarding_needs_api_key = false; |
| 731 | app.onboarding_had_trust_step = true; |
| 732 | app.onboarding = OnboardingState::TrustDirectory; |
| 733 | assert_eq!(required_progress(&app), Some((3, 3))); |
| 734 | |
| 735 | // Welcome and ready are not decisions and never carry a counter. |
| 736 | app.onboarding = OnboardingState::Welcome; |
| 737 | assert_eq!(required_progress(&app), None); |
| 738 | app.onboarding = OnboardingState::Ready; |
| 739 | assert_eq!(required_progress(&app), None); |
| 740 | } |
| 741 | |
| 742 | // ── Language inference gate ─────────────────────────────────────────── |
| 743 | |
| 744 | #[test] |
| 745 | fn language_step_is_required_only_when_the_locale_is_not_inferable() { |
| 746 | let _env_lock = crate::test_support::lock_test_env(); |
| 747 | let _guard = crate::test_support::EnvVarGuard::remove("LC_ALL"); |
| 748 | let _messages = crate::test_support::EnvVarGuard::remove("LC_MESSAGES"); |
| 749 | let _lang = crate::test_support::EnvVarGuard::remove("LANG"); |
| 750 | |
| 751 | assert!(!locale_confidently_inferred("auto")); |
| 752 | assert!(!locale_confidently_inferred("")); |
| 753 | |
| 754 | // An explicit settings pick is always confident. |
| 755 | assert!(locale_confidently_inferred("ja")); |
| 756 | assert!(locale_confidently_inferred("zh-Hans")); |
| 757 | |
| 758 | // A shipped locale in the environment is confident… |
| 759 | let _lang = crate::test_support::EnvVarGuard::set("LANG", "ja_JP.UTF-8"); |
| 760 | assert!(locale_confidently_inferred("auto")); |
| 761 | |
| 762 | // …but a POSIX/C environment is not a language signal. |
| 763 | let _lang = crate::test_support::EnvVarGuard::set("LANG", "C"); |
| 764 | assert!(!locale_confidently_inferred("auto")); |
| 765 | let _lang = crate::test_support::EnvVarGuard::set("LANG", "C.UTF-8"); |
| 766 | assert!(!locale_confidently_inferred("auto")); |
| 767 | } |
| 768 | |
| 769 | // ── The seeded first task ──────────────────────────────────────────── |
| 770 | |
| 771 | #[test] |
| 772 | fn seed_speaks_to_the_workspace_contents() { |
| 773 | let code_dir = tempfile::tempdir().expect("tempdir"); |
| 774 | std::fs::write(code_dir.path().join("Cargo.toml"), "[package]\n").expect("marker"); |
| 775 | assert_eq!( |
| 776 | first_task_seed(code_dir.path(), Locale::En), |
| 777 | tr(Locale::En, MessageId::OnboardSeedCodeProject) |
| 778 | ); |
| 779 | |
| 780 | let plain_dir = tempfile::tempdir().expect("tempdir"); |
| 781 | std::fs::write(plain_dir.path().join("README.md"), "notes\n").expect("readme"); |
| 782 | assert_eq!( |
| 783 | first_task_seed(plain_dir.path(), Locale::En), |
| 784 | tr(Locale::En, MessageId::OnboardSeedFolder) |
| 785 | ); |
| 786 | } |
| 787 | |
| 788 | #[test] |
| 789 | fn finishing_from_ready_seeds_the_composer_and_marks_onboarding_done() { |
| 790 | let _env_lock = crate::test_support::lock_test_env(); |
| 791 | let home = tempfile::tempdir().expect("home"); |
| 792 | let _home = crate::test_support::EnvVarGuard::set("HOME", home.path()); |
| 793 | let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", home.path()); |
| 794 | let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path()); |
| 795 | |
| 796 | let workspace = tempfile::tempdir().expect("workspace"); |
| 797 | std::fs::write(workspace.path().join("package.json"), "{}\n").expect("marker"); |
| 798 | |
| 799 | let mut app = test_app_with_locale(Locale::En); |
| 800 | app.workspace = workspace.path().to_path_buf(); |
| 801 | app.onboarding = OnboardingState::Ready; |
| 802 | |
| 803 | finish_ready_and_open_composer(&mut app); |
| 804 | |
| 805 | assert_eq!(app.onboarding, OnboardingState::None); |
| 806 | assert!(is_onboarded(), "the ready screen completes first run"); |
| 807 | assert_eq!( |
| 808 | app.composer.input, |
| 809 | tr(Locale::En, MessageId::OnboardSeedCodeProject) |
| 810 | ); |
| 811 | assert_eq!( |
| 812 | app.composer.cursor_position, |
| 813 | app.composer.input.chars().count() |
| 814 | ); |
| 815 | } |
| 816 | |
| 817 | #[test] |
| 818 | fn finishing_from_ready_preserves_a_real_cli_prompt() { |
| 819 | let _env_lock = crate::test_support::lock_test_env(); |
| 820 | let home = tempfile::tempdir().expect("home"); |
| 821 | let _home = crate::test_support::EnvVarGuard::set("HOME", home.path()); |
| 822 | let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", home.path()); |
| 823 | let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path()); |
| 824 | |
| 825 | let mut app = test_app_with_locale(Locale::En); |
| 826 | app.onboarding = OnboardingState::Ready; |
| 827 | app.composer.input = "Fix the failing build I asked for.".to_string(); |
| 828 | app.composer.cursor_position = app.composer.input.chars().count(); |
| 829 | |
| 830 | finish_ready_and_open_composer(&mut app); |
| 831 | |
| 832 | assert_eq!(app.onboarding, OnboardingState::None); |
| 833 | assert_eq!(app.composer.input, "Fix the failing build I asked for."); |
| 834 | assert_eq!( |
| 835 | app.composer.cursor_position, |
| 836 | app.composer.input.chars().count() |
| 837 | ); |
| 838 | } |
| 839 | |
| 840 | // ── Onboarded-state persistence contract ───────────────────────────── |
| 841 | |
| 842 | #[test] |
| 843 | fn fresh_install_marker_path_uses_codewhale_not_legacy() { |
| 844 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 845 | |
| 846 | let expected = tmp.path().join(".codewhale").join(ONBOARDED_MARKER_FILE); |
| 847 | assert_eq!(marker_path_with_home(tmp.path()), expected); |
| 848 | |
| 849 | let written = mark_onboarded_at_home(tmp.path()).expect("mark onboarded"); |
| 850 | assert_eq!(written, expected); |
| 851 | assert!(expected.exists()); |
| 852 | assert!( |
| 853 | !tmp.path().join(".deepseek").exists(), |
| 854 | "fresh onboarding must not recreate the legacy .deepseek dir" |
| 855 | ); |
| 856 | } |
| 857 | |
| 858 | #[test] |
| 859 | fn existing_legacy_marker_is_preserved() { |
| 860 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 861 | let legacy = tmp.path().join(".deepseek").join(ONBOARDED_MARKER_FILE); |
| 862 | std::fs::create_dir_all(legacy.parent().expect("legacy parent")).expect("mkdir legacy"); |
| 863 | std::fs::write(&legacy, "").expect("seed legacy marker"); |
| 864 | |
| 865 | assert_eq!(marker_path_with_home(tmp.path()), legacy); |
| 866 | assert_eq!( |
| 867 | mark_onboarded_at_home(tmp.path()).expect("mark onboarded"), |
| 868 | legacy |
| 869 | ); |
| 870 | } |
| 871 | |
| 872 | #[test] |
| 873 | fn codewhale_marker_wins_over_legacy_marker() { |
| 874 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 875 | let primary = tmp.path().join(".codewhale").join(ONBOARDED_MARKER_FILE); |
| 876 | let legacy = tmp.path().join(".deepseek").join(ONBOARDED_MARKER_FILE); |
| 877 | for marker in [&primary, &legacy] { |
| 878 | std::fs::create_dir_all(marker.parent().expect("marker parent")).expect("mkdir"); |
| 879 | std::fs::write(marker, "").expect("seed marker"); |
| 880 | } |
| 881 | |
| 882 | assert_eq!(marker_path_with_home(tmp.path()), primary); |
| 883 | } |
| 884 | |
| 885 | #[test] |
| 886 | fn explicit_codewhale_home_marker_survives_restart_resolution() { |
| 887 | let _env_lock = crate::test_support::lock_test_env(); |
| 888 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 889 | let ambient_home = tmp.path().join("ambient profile"); |
| 890 | let isolated_home = tmp.path().join("isolated Codewhale state"); |
| 891 | let ambient_legacy = ambient_home.join(".deepseek").join(ONBOARDED_MARKER_FILE); |
| 892 | std::fs::create_dir_all(ambient_legacy.parent().expect("legacy parent")).expect("mkdir"); |
| 893 | std::fs::write(&ambient_legacy, "").expect("seed ambient legacy marker"); |
| 894 | let _home = crate::test_support::EnvVarGuard::set("HOME", &ambient_home); |
| 895 | let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &ambient_home); |
| 896 | let _codewhale_home = |
| 897 | crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &isolated_home); |
| 898 | |
| 899 | let expected = isolated_home.join(ONBOARDED_MARKER_FILE); |
| 900 | assert_eq!(default_marker_path().as_deref(), Some(expected.as_path())); |
| 901 | assert!(!is_onboarded()); |
| 902 | |
| 903 | let written = mark_onboarded().expect("mark onboarded"); |
| 904 | |
| 905 | assert_eq!(written, expected); |
| 906 | assert!(is_onboarded()); |
| 907 | assert_eq!(default_marker_path().as_deref(), Some(expected.as_path())); |
| 908 | assert!(ambient_legacy.exists(), "legacy marker remains untouched"); |
| 909 | assert!( |
| 910 | !ambient_home.join(".codewhale").exists(), |
| 911 | "an explicit state root must not write into the ambient profile" |
| 912 | ); |
| 913 | } |
| 914 | |
| 915 | // ── Locale completeness for the new copy ───────────────────────────── |
| 916 | |
| 917 | #[test] |
| 918 | fn calm_onboarding_copy_is_translated_in_every_complete_pack() { |
| 919 | for locale in Locale::shipped_complete() { |
| 920 | for id in [ |
| 921 | MessageId::OnboardWelcomeTitle, |
| 922 | MessageId::OnboardWelcomeLead, |
| 923 | MessageId::OnboardWelcomeBegin, |
| 924 | MessageId::OnboardActionBack, |
| 925 | MessageId::OnboardActionExit, |
| 926 | MessageId::OnboardStepsTitle, |
| 927 | MessageId::OnboardLanguagePick, |
| 928 | MessageId::OnboardLanguageKeep, |
| 929 | MessageId::OnboardProviderChoose, |
| 930 | MessageId::OnboardProviderOffline, |
| 931 | MessageId::OnboardTrustActionTrust, |
| 932 | MessageId::OnboardTrustActionSkip, |
| 933 | MessageId::OnboardTrustActionQuit, |
| 934 | MessageId::OnboardReadyTitle, |
| 935 | MessageId::OnboardReadyLead, |
| 936 | MessageId::OnboardReadyStart, |
| 937 | MessageId::OnboardReadyCustomize, |
| 938 | MessageId::CmdRemoteControlDescription, |
| 939 | MessageId::OnboardSeedCodeProject, |
| 940 | MessageId::OnboardSeedFolder, |
| 941 | ] { |
| 942 | let text = tr(*locale, id); |
| 943 | assert!(!text.is_empty(), "{locale:?} {id:?} is empty"); |
| 944 | if *locale != Locale::En { |
| 945 | assert_ne!( |
| 946 | text, |
| 947 | tr(Locale::En, id), |
| 948 | "{locale:?} {id:?} silently fell back to English" |
| 949 | ); |
| 950 | } |
| 951 | } |
| 952 | } |
| 953 | } |
| 954 | |
| 955 | #[test] |
| 956 | fn provider_screen_advertises_the_offline_choice() { |
| 957 | use crate::tui::views::action_footer_lines; |
| 958 | |
| 959 | let mut app = test_app_with_locale(Locale::En); |
| 960 | app.onboarding = OnboardingState::Provider; |
| 961 | let rail = flattened(action_footer_lines(&action_hints(&app), 70)); |
| 962 | assert!(rail.contains("Enter"), "primary choice first: {rail}"); |
| 963 | assert!( |
| 964 | rail.contains("Ctrl+O"), |
| 965 | "offline exit must be advertised: {rail}" |
| 966 | ); |
| 967 | assert!( |
| 968 | rail.contains(tr(Locale::En, MessageId::OnboardProviderOffline).as_ref()), |
| 969 | "offline exit needs its translated label: {rail}" |
| 970 | ); |
| 971 | } |
| 972 | |
| 973 | #[test] |
| 974 | fn wrap_words_breaks_scripts_that_have_no_spaces() { |
| 975 | use unicode_width::UnicodeWidthStr; |
| 976 | // The real ja provider blurb. `split_whitespace` yields one token, so |
| 977 | // the word-only wrapper emitted a single 110-column line and an 80-column |
| 978 | // terminal clipped everything after "ローカ" — the half of the sentence |
| 979 | // that tells the user local runtimes need no key. |
| 980 | let ja = "モデルの実行先を選びます。ホステッドプロバイダーにはキーが必要ですが、ローカルランタイムはキーなしで続行できます。"; |
| 981 | assert!( |
| 982 | ja.split_whitespace().count() == 1, |
| 983 | "fixture must be a single whitespace-delimited token" |
| 984 | ); |
| 985 | |
| 986 | let lines = wrap_words(ja, 76); |
| 987 | assert!( |
| 988 | lines.len() > 1, |
| 989 | "space-less text must wrap, not clip: {lines:?}" |
| 990 | ); |
| 991 | for line in &lines { |
| 992 | assert!( |
| 993 | UnicodeWidthStr::width(line.as_str()) <= 76, |
| 994 | "line exceeds the lane: {:?} ({} cols)", |
| 995 | line, |
| 996 | UnicodeWidthStr::width(line.as_str()) |
| 997 | ); |
| 998 | } |
| 999 | // Nothing may be dropped: the rejoined lines must reproduce the source. |
| 1000 | assert_eq!(lines.concat(), ja, "wrapping must not lose characters"); |
| 1001 | } |
| 1002 | |
| 1003 | #[test] |
| 1004 | fn wrap_words_keeps_latin_wrapping_unchanged() { |
| 1005 | let text = "Pick where your model runs. Hosted providers need a key; local runtimes can continue without one."; |
| 1006 | let lines = wrap_words(text, 60); |
| 1007 | assert!(lines.len() >= 2); |
| 1008 | for line in &lines { |
| 1009 | assert!(line.len() <= 60, "{line:?}"); |
| 1010 | assert!(!line.starts_with(' ') && !line.ends_with(' '), "{line:?}"); |
| 1011 | } |
| 1012 | assert_eq!(lines.join(" "), text, "word wrapping must round-trip"); |
| 1013 | } |
| 1014 | |
| 1015 | #[test] |
| 1016 | fn wrap_words_does_not_open_a_line_with_japanese_closing_punctuation() { |
| 1017 | // Kinsoku: 。、」) and friends may not begin a line. |
| 1018 | let text = "あいうえおかきくけこさしすせそたちつてと。"; |
| 1019 | for width in 4..=20 { |
| 1020 | for line in wrap_words(text, width) { |
| 1021 | let first = line.chars().next().unwrap(); |
| 1022 | assert!( |
| 1023 | !NO_LINE_START.contains(&first), |
| 1024 | "width {width}: line starts with {first:?} in {line:?}" |
| 1025 | ); |
| 1026 | } |
| 1027 | } |
| 1028 | } |
| 1029 | |
| 1030 | #[test] |
| 1031 | fn wrap_words_handles_mixed_latin_and_cjk() { |
| 1032 | let text = "Codewhale はこのフォルダーで一緒に作業します。"; |
| 1033 | let lines = wrap_words(text, 20); |
| 1034 | assert_eq!( |
| 1035 | lines.join("").replace(' ', ""), |
| 1036 | text.replace(' ', ""), |
| 1037 | "mixed-script text must not lose characters: {lines:?}" |
| 1038 | ); |
| 1039 | } |
| 1040 | } |
| 1041 |