1use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use oino_ai::{JsonValue, Model, ThinkingLevel};
7use oino_core::builtin::{SessionToolContext, builtin_tool_entries};
8use oino_core::extensions::api::{ExtensionMode, FlagType};
9use oino_core::runtime::{AgentSessionRuntime, SessionOptions, SessionSource, ToolSelection};
10use oino_core::safe_mode::{FailureEntry, FailureStage, SafeMode};
11use oino_core::services::{ResourceFlags, Services, ServicesOptions};
12use oino_models::resolver::{
13 CliModelRequest, ModelCatalog, ScopedModel, resolve_cli_model_in, resolve_model_scope_in,
14};
15use oino_session::SessionManager;
16
17use crate::cli::args::{Args, Diagnostic, normalize_session_name};
18use crate::cli::help::ExtensionFlag;
19use crate::loader::{CliLoader, EXTENSION_LOAD_FAILURE_HINT, LoaderContext};
20
21fn settings_snapshot(services: &Services) -> oino_settings::Settings {
22 services
23 .settings
24 .lock()
25 .unwrap_or_else(std::sync::PoisonError::into_inner)
26 .snapshot()
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum AppMode {
32 Interactive,
34 Print,
36 Json,
38 Rpc,
40 Acp,
42}
43
44impl AppMode {
45 #[must_use]
47 pub fn resolve(args: &Args, stdin_tty: bool, stdout_tty: bool) -> Self {
48 use crate::cli::args::OutputMode;
49 match args.mode {
50 Some(OutputMode::Rpc) => Self::Rpc,
51 Some(OutputMode::Json) => Self::Json,
52 Some(OutputMode::Acp) => Self::Acp,
53 _ if args.print || !stdin_tty || !stdout_tty => Self::Print,
54 _ => Self::Interactive,
55 }
56 }
57
58 const fn extension_mode(self) -> ExtensionMode {
59 match self {
60 Self::Interactive => ExtensionMode::Tui,
61 Self::Print => ExtensionMode::Print,
62 Self::Json => ExtensionMode::Json,
63 Self::Rpc => ExtensionMode::Rpc,
64 Self::Acp => ExtensionMode::Acp,
65 }
66 }
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct StartupError(pub Vec<String>);
72
73impl StartupError {
74 fn one(message: impl Into<String>) -> Self {
75 Self(vec![message.into()])
76 }
77}
78
79pub struct Started {
81 pub runtime: Arc<AgentSessionRuntime>,
83 pub diagnostics: Vec<Diagnostic>,
85 pub extension_flags: Vec<ExtensionFlag>,
87 pub sessions_root: PathBuf,
89 pub project_trusted: bool,
91}
92
93impl std::fmt::Debug for Started {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 f.debug_struct("Started")
96 .field("diagnostics", &self.diagnostics)
97 .finish_non_exhaustive()
98 }
99}
100
101pub fn validate_session_flags(args: &Args) -> Result<(), StartupError> {
107 let flags = |pairs: &[(bool, &str)]| -> Vec<String> {
108 pairs
109 .iter()
110 .filter(|(set, _)| *set)
111 .map(|(_, name)| (*name).to_owned())
112 .collect()
113 };
114 if args.fork.is_some() {
115 let conflicts = flags(&[
116 (args.session.is_some(), "--session"),
117 (args.continue_session, "--continue"),
118 (args.resume, "--resume"),
119 (args.no_session, "--no-session"),
120 ]);
121 if !conflicts.is_empty() {
122 return Err(StartupError::one(format!(
123 "Error: --fork cannot be combined with {}",
124 conflicts.join(", ")
125 )));
126 }
127 }
128 if let Some(id) = &args.session_id {
129 let conflicts = flags(&[
130 (args.session.is_some(), "--session"),
131 (args.continue_session, "--continue"),
132 (args.resume, "--resume"),
133 ]);
134 if !conflicts.is_empty() {
135 return Err(StartupError::one(format!(
136 "Error: --session-id cannot be combined with {}",
137 conflicts.join(", ")
138 )));
139 }
140 oino_session::manager::validate_session_id(id)
141 .map_err(|e| StartupError::one(format!("Error: {e}")))?;
142 }
143 Ok(())
144}
145
146enum Resolved {
147 Path(PathBuf),
148 Global(PathBuf, String),
149 NotFound,
150}
151
152#[allow(
154 clippy::case_sensitive_file_extension_comparisons,
155 reason = "`.jsonl` matches case-sensitively"
156)]
157fn resolve_session_path(arg: &str, cwd: &Path, root: &Path) -> Resolved {
158 if arg.contains('/') || arg.contains('\\') || arg.ends_with(".jsonl") {
159 let home = oino_settings::paths::home_dir();
160 return Resolved::Path(oino_settings::paths::resolve_path(
161 arg,
162 cwd,
163 home.as_deref(),
164 false,
165 ));
166 }
167 if let Ok(Some(path)) = SessionManager::find_by_id(cwd, arg, root)
168 && path.parent() == Some(&oino_session::manager::cwd_session_dir(root, cwd))
169 {
170 return Resolved::Path(path);
171 }
172 if let Ok(local) = SessionManager::list(cwd, root, None, None)
173 && let Some(found) = local.iter().find(|s| s.id.starts_with(arg))
174 {
175 return Resolved::Path(found.path.clone());
176 }
177 if let Ok(all) = SessionManager::list_all(root, None, None)
178 && let Some(found) = all
179 .iter()
180 .find(|s| s.id == arg)
181 .or_else(|| all.iter().find(|s| s.id.starts_with(arg)))
182 {
183 return Resolved::Global(found.path.clone(), found.cwd.clone());
184 }
185 Resolved::NotFound
186}
187
188fn session_source(
190 args: &Args,
191 metadata_only: bool,
192 cwd: &Path,
193 root: &Path,
194) -> Result<SessionSource, StartupError> {
195 if args.no_session || metadata_only {
196 return Ok(SessionSource::InMemory);
197 }
198 let not_found = |arg: &str| StartupError::one(format!("No session found matching '{arg}'"));
199 if let Some(fork) = &args.fork {
200 if args.session_id.is_some() {
201 return Err(StartupError::one(
202 "Error: --fork with --session-id is not supported yet",
203 ));
204 }
205 return match resolve_session_path(fork, cwd, root) {
206 Resolved::Path(path) | Resolved::Global(path, _) => Ok(SessionSource::Fork(path)),
207 Resolved::NotFound => Err(not_found(fork)),
208 };
209 }
210 if let Some(session) = &args.session {
211 return match resolve_session_path(session, cwd, root) {
212 Resolved::Path(path) => Ok(SessionSource::Open(path)),
213 Resolved::Global(_, other) => Err(StartupError(vec![
214 format!("Session found in different project: {other}"),
215 "Use --fork to copy it into the current directory.".to_owned(),
216 ])),
217 Resolved::NotFound => Err(not_found(session)),
218 };
219 }
220 if args.resume {
221 return Err(StartupError::one(
222 "Error: --resume needs the interactive session picker; use --session <id> or --continue",
223 ));
224 }
225 if args.continue_session {
226 return Ok(SessionSource::ContinueRecent);
227 }
228 if let Some(id) = &args.session_id {
229 return match SessionManager::find_by_id(cwd, id, root) {
231 Ok(Some(path)) => Ok(SessionSource::Open(path)),
232 Ok(None) => Ok(SessionSource::NewWithId(id.clone())),
233 Err(error) => Err(StartupError::one(format!("Error: {error}"))),
234 };
235 }
236 Ok(SessionSource::New)
237}
238
239fn has_existing_session(source: &SessionSource, cwd: &Path, root: &Path) -> bool {
241 let path = match source {
242 SessionSource::Open(path) | SessionSource::Fork(path) => Some(path.clone()),
243 SessionSource::ContinueRecent => SessionManager::find_most_recent(cwd, root).ok().flatten(),
244 SessionSource::Resume(_)
245 | SessionSource::New
246 | SessionSource::NewWithId(_)
247 | SessionSource::InMemory => None,
248 };
249 path.and_then(|p| SessionManager::open(&p, root, Some(cwd)).ok())
250 .is_some_and(|m| !m.build_session_context().messages.is_empty())
251}
252
253struct ModelChoice {
254 model: Option<Model>,
255 thinking: Option<ThinkingLevel>,
256 scoped: Vec<ScopedModel>,
257}
258
259fn choose_model(
261 args: &Args,
262 services: &Services,
263 has_existing: bool,
264 diagnostics: &mut Vec<Diagnostic>,
265) -> ModelChoice {
266 let models = &services.models;
267 let settings = settings_snapshot(services);
268 let mut choice = ModelChoice {
269 model: None,
270 thinking: None,
271 scoped: Vec::new(),
272 };
273 if let (Some(provider), None) = (&args.provider, &args.model) {
274 diagnostics.push(Diagnostic::error(format!(
275 "--provider requires --model (for example: --provider {provider} --model <pattern>)"
276 )));
277 }
278 if args.model.is_some() {
279 let request = CliModelRequest {
280 provider: args.provider.as_deref(),
281 model: args.model.as_deref(),
282 thinking: args.thinking,
283 inline_api_key: args.api_key.is_some(),
284 };
285 match resolve_cli_model_in(&request, models) {
286 Ok(Some(resolved)) => {
287 if let Some(warning) = resolved.warning {
288 diagnostics.push(Diagnostic::warning(warning));
289 }
290 if args.thinking.is_none() {
291 choice.thinking = resolved.thinking_level;
292 }
293 choice.model = Some(resolved.model);
294 }
295 Ok(None) => {}
296 Err(error) => diagnostics.push(Diagnostic::error(error.0)),
297 }
298 }
299 let patterns = args.models.clone().or_else(|| settings.enabled_models());
300 if let Some(patterns) = patterns.filter(|p| !p.is_empty()) {
301 let scope = resolve_model_scope_in(&patterns, models);
302 diagnostics.extend(
303 scope
304 .diagnostics
305 .into_iter()
306 .map(|d| Diagnostic::warning(d.message)),
307 );
308 choice.scoped = scope.scoped_models;
309 }
310 if choice.model.is_none() && !choice.scoped.is_empty() && !has_existing {
311 let saved = settings
312 .default_provider()
313 .zip(settings.default_model())
314 .and_then(|(p, id)| ModelCatalog::find(models, p, id));
315 let pick = saved
316 .and_then(|saved| {
317 choice
318 .scoped
319 .iter()
320 .find(|s| s.model.provider == saved.provider && s.model.id == saved.id)
321 })
322 .or_else(|| choice.scoped.first())
323 .cloned();
324 if let Some(pick) = pick {
325 if args.thinking.is_none() {
326 choice.thinking = pick.thinking_level;
327 }
328 choice.model = Some(pick.model);
329 }
330 }
331 if args.thinking.is_some() {
332 choice.thinking = args.thinking;
333 }
334 choice
335}
336
337pub(crate) fn acp_model_choice(args: &Args, services: &Services) -> oino_acp::ModelChoice {
340 let mut diagnostics = Vec::new();
341 let choice = choose_model(args, services, false, &mut diagnostics);
342 for line in crate::report_lines(&diagnostics) {
343 crate::eprintln_line(&line);
344 }
345 oino_acp::ModelChoice {
346 model: choice.model,
347 thinking_level: choice.thinking,
348 scoped_models: choice.scoped,
349 }
350}
351
352pub(crate) fn docs_root(agent_dir: &Path) -> PathBuf {
356 oino_core::extensions::sources::docs_root(agent_dir).unwrap_or_default()
357}
358
359pub(crate) fn resource_flags(args: &Args, cwd: &Path) -> ResourceFlags {
361 ResourceFlags {
362 no_skills: args.no_skills,
363 no_prompt_templates: args.no_prompt_templates,
364 no_themes: args.no_themes,
365 no_context_files: args.no_context_files,
366 skill_paths: resolve_cli_paths(cwd, args.skills.as_ref()),
367 prompt_template_paths: resolve_cli_paths(cwd, args.prompt_templates.as_ref()),
368 theme_paths: resolve_cli_paths(cwd, args.themes.as_ref()),
369 system_prompt: args.system_prompt.clone(),
370 append_system_prompt: args.append_system_prompt.clone(),
371 no_extensions: args.no_extensions,
372 safe_mode: None,
373 offline: args.offline,
374 }
375}
376
377pub(crate) fn tool_selection(args: &Args) -> ToolSelection {
379 match &args.tools {
380 Some(tools) => ToolSelection::List(tools.clone()),
381 None if args.no_tools => ToolSelection::None,
382 None if args.no_builtin_tools => ToolSelection::NoBuiltin,
383 None => ToolSelection::Default,
384 }
385}
386
387fn resolve_cli_paths(cwd: &Path, paths: Option<&Vec<String>>) -> Vec<String> {
388 let home = oino_settings::paths::home_dir();
389 paths
390 .into_iter()
391 .flatten()
392 .map(|value| {
393 if value.contains("://") || value.starts_with("npm:") || value.starts_with("git:") {
394 value.clone()
395 } else {
396 oino_settings::paths::resolve_path(value, cwd, home.as_deref(), false)
397 .display()
398 .to_string()
399 }
400 })
401 .collect()
402}
403
404fn check_extension_flags(
406 args: &Args,
407 services: &Services,
408 diagnostics: &mut Vec<Diagnostic>,
409) -> (Vec<ExtensionFlag>, Vec<String>) {
410 let mut registered = Vec::new();
411 for extension in services.extensions.extensions() {
412 for flag in extension.state().flags.into_values() {
413 registered.push(ExtensionFlag {
414 extension_path: extension.instance.path(),
415 flag,
416 });
417 }
418 }
419 let mut unknown = Vec::new();
420 let mut booleans = Vec::new();
421 for (name, value) in &args.unknown_flags {
422 match registered.iter().find(|f| &f.flag.name == name) {
423 None => unknown.push(format!("--{name}")),
424 Some(flag) if flag.flag.kind == FlagType::Boolean => booleans.push(name.clone()),
425 Some(_) if value.is_string() => {}
426 Some(_) => diagnostics.push(Diagnostic::error(format!(
427 "Extension flag \"--{name}\" requires a value"
428 ))),
429 }
430 }
431 if !unknown.is_empty() {
432 diagnostics.push(Diagnostic::error(format!(
433 "Unknown option{}: {}",
434 if unknown.len() == 1 { "" } else { "s" },
435 unknown.join(", ")
436 )));
437 }
438 (registered, booleans)
439}
440
441#[derive(Clone, Debug)]
443pub struct Environment {
444 pub cwd: PathBuf,
446 pub agent_dir: PathBuf,
448}
449
450const SUSPECT_LIMIT: usize = 3;
452
453fn extension_suspects(lines: &[String]) -> Vec<(String, String)> {
456 let id = |text: &str| {
457 let text = text.trim();
458 let valid = |id: &str| {
459 !id.is_empty()
460 && id.split('.').all(|segment| {
461 let mut chars = segment.chars();
462 chars
463 .next()
464 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
465 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
466 })
467 };
468 for prefix in ["Failed to load extension \"", "Bundled extension \""] {
469 if let Some(rest) = text.strip_prefix(prefix)
470 && let Some((id, error)) = rest.split_once("\": ")
471 && valid(id)
472 {
473 return Some((id.to_owned(), error.to_owned()));
474 }
475 }
476 if let Some((id, error)) = text.split_once(": ")
477 && valid(id)
478 && !error.is_empty()
479 {
480 return Some((id.to_owned(), error.to_owned()));
481 }
482 None
483 };
484 let mut suspects = Vec::new();
485 for line in lines.iter().rev() {
486 if let Some((id, error)) = id(line)
487 && !suspects
488 .iter()
489 .any(|(known, _): &(String, String)| *known == id)
490 {
491 suspects.push((id, error));
492 }
493 }
494 suspects.truncate(SUSPECT_LIMIT);
495 suspects.reverse();
496 suspects
497}
498
499fn record_start_failure(
502 safe_mode: &mut SafeMode,
503 stage: FailureStage,
504 error: String,
505 suspect_lines: &[String],
506) {
507 for (id, error) in extension_suspects(suspect_lines) {
508 safe_mode.note_suspect(&id, error);
509 }
510 safe_mode.record_failure(FailureEntry::new(stage, error));
511}
512
513#[allow(
519 clippy::too_many_lines,
520 reason = "linear startup; splitting hides the order"
521)]
522pub async fn start(args: &Args, mode: AppMode, env: &Environment) -> Result<Started, StartupError> {
523 let metadata_only = args.help || args.list_models.is_some();
524 let mut safe_mode = SafeMode::load(&env.agent_dir);
527 safe_mode.begin_start();
528 let plan = safe_mode.plan();
529 let loader = Arc::new(CliLoader::new(
530 args.extensions.as_deref().unwrap_or_default(),
531 &LoaderContext {
532 agent_dir: env.agent_dir.clone(),
533 cwd: env.cwd.clone(),
534 no_extensions: args.no_extensions,
535 exclude_extensions: args.exclude_extensions.clone().unwrap_or_default(),
536 },
537 ));
538 let mut diagnostics: Vec<Diagnostic> = loader.diagnostics().to_vec();
539 let mut flags = resource_flags(args, &env.cwd);
540 flags.safe_mode = Some(plan);
541 let services = match Services::create(ServicesOptions {
542 cwd: env.cwd.clone(),
543 agent_dir: env.agent_dir.clone(),
544 docs_root: docs_root(&env.agent_dir),
545 mode: mode.extension_mode(),
546 has_ui: matches!(mode, AppMode::Rpc | AppMode::Interactive),
547 flags,
548 extension_flag_values: args.unknown_flags.clone(),
549 loader: Arc::clone(&loader) as Arc<dyn oino_core::services::ExtensionLoader>,
550 approve: args.project_trust_override,
551 models: None,
552 settings: None,
553 })
554 .await
555 {
556 Ok(services) => services,
557 Err(e) => {
558 let error = format!("Error: {e}");
559 record_start_failure(
560 &mut safe_mode,
561 FailureStage::Services,
562 error.clone(),
563 &loader
564 .diagnostics()
565 .iter()
566 .map(|d| d.message.clone())
567 .collect::<Vec<_>>(),
568 );
569 return Err(StartupError::one(error));
570 }
571 };
572 diagnostics.extend(loader.load_errors());
573 diagnostics.extend(
574 services
575 .diagnostics
576 .iter()
577 .map(|d| Diagnostic::warning(d.clone())),
578 );
579 let (extension_flags, boolean_flags) = check_extension_flags(args, &services, &mut diagnostics);
580 let root = services.sessions_root(args.session_dir.as_deref().map(Path::new));
581 let project_trusted = services.project_trusted;
582 let source = match session_source(args, metadata_only, &env.cwd, &root) {
585 Ok(source) => source,
586 Err(error) => {
587 safe_mode.end_start();
588 return Err(error);
589 }
590 };
591 let has_existing = has_existing_session(&source, &env.cwd, &root);
592 let choice = choose_model(args, &services, has_existing, &mut diagnostics);
593 if let Some(key) = &args.api_key {
594 match &choice.model {
595 Some(model) => services.models.set_runtime_api_key(&model.provider, key.clone()),
596 None => diagnostics.push(Diagnostic::error(
597 "--api-key requires a model to be specified via --model, --provider/--model, or --models",
598 )),
599 }
600 }
601 let name = match &args.name {
602 Some(raw) if normalize_session_name(raw).is_none() => {
603 safe_mode.end_start();
604 return Err(StartupError::one(
605 "Error: --name requires a non-empty value",
606 ));
607 }
608 Some(raw) => normalize_session_name(raw),
609 None => None,
610 };
611 let context = Arc::new(SessionToolContext::default());
612 let settings = settings_snapshot(&services);
613 let builtin_tools = builtin_tool_entries(
614 &services.cwd,
615 &services.agent_dir,
616 &settings,
617 Arc::clone(&context),
618 );
619 let options = SessionOptions {
620 source,
621 session_dir: args.session_dir.as_ref().map(PathBuf::from),
622 name,
623 model: choice.model,
624 thinking_level: choice.thinking,
625 scoped_models: choice.scoped,
626 tools: tool_selection(args),
627 exclude_tools: args.exclude_tools.clone().unwrap_or_default(),
628 builtin_tools,
629 custom_tools: Vec::new(),
630 };
631 let runtime = match AgentSessionRuntime::create(Arc::clone(&services), options).await {
632 Ok(runtime) => runtime,
633 Err(e) => {
634 let error = format!("Error: {e}");
635 let mut lines = diagnostics
636 .iter()
637 .map(|d| d.message.clone())
638 .collect::<Vec<_>>();
639 lines.extend(loader.load_errors().iter().map(|d| d.message.clone()));
640 lines.extend(services.diagnostics.iter().cloned());
641 record_start_failure(&mut safe_mode, FailureStage::Session, error.clone(), &lines);
642 return Err(StartupError::one(error));
643 }
644 };
645 safe_mode.record_success();
647 let session = runtime.session();
648 context.bind(&session);
649 let runner = session.runner();
650 for name in boolean_flags {
651 runner.set_flag_value(&name, JsonValue::Bool(true));
652 }
653 if metadata_only {
654 return Ok(Started {
655 runtime: Arc::new(runtime),
656 diagnostics,
657 extension_flags,
658 sessions_root: root,
659 project_trusted,
660 });
661 }
662 if diagnostics
663 .iter()
664 .any(|d| d.severity == crate::cli::args::Severity::Error)
665 {
666 let mut lines = crate::report_lines(&diagnostics);
667 if diagnostics
668 .iter()
669 .any(|d| d.message.contains("Failed to load extension"))
670 {
671 lines.push(EXTENSION_LOAD_FAILURE_HINT.to_owned());
672 }
673 runtime.dispose().await;
674 let mut suspect_lines = diagnostics
677 .iter()
678 .map(|d| d.message.clone())
679 .collect::<Vec<_>>();
680 suspect_lines.extend(loader.load_errors().iter().map(|d| d.message.clone()));
681 let error = diagnostics
682 .iter()
683 .find(|d| d.severity == crate::cli::args::Severity::Error)
684 .map_or_else(
685 || "startup diagnostics reported errors".to_owned(),
686 |d| d.message.clone(),
687 );
688 record_start_failure(
689 &mut safe_mode,
690 FailureStage::Services,
691 error,
692 &suspect_lines,
693 );
694 return Err(StartupError(lines));
695 }
696 if mode != AppMode::Interactive && session.model().is_none() {
697 let message = runtime.model_fallback_message().map_or_else(
698 || {
699 oino_models::auth::guidance::no_models_available_message(
700 &services.docs_root.join("docs"),
701 )
702 },
703 str::to_owned,
704 );
705 let mut lines = crate::report_lines(&diagnostics);
706 lines.push(message);
707 runtime.dispose().await;
708 safe_mode.end_start();
710 return Err(StartupError(lines));
711 }
712 Ok(Started {
713 runtime: Arc::new(runtime),
714 diagnostics,
715 extension_flags,
716 sessions_root: root,
717 project_trusted,
718 })
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724
725 #[test]
726 fn extension_suspects_names_extension_failures() {
727 let lines = vec![
728 "acme.notify: init failed: boom".to_owned(),
729 "Excluded extension \"acme.notify\" not loaded".to_owned(),
730 "Failed to load extension \"acme.kanboard\": no component.wasm".to_owned(),
731 "Bundled extension \"oino.docs\": extract failed".to_owned(),
732 "Unknown option: --nope".to_owned(),
733 ];
734 assert_eq!(
735 extension_suspects(&lines),
736 vec![
737 ("acme.notify".to_owned(), "init failed: boom".to_owned()),
738 ("acme.kanboard".to_owned(), "no component.wasm".to_owned()),
739 ("oino.docs".to_owned(), "extract failed".to_owned()),
740 ]
741 );
742 }
743
744 #[test]
745 fn extension_suspects_deduplicates_and_caps() {
746 let lines = vec![
747 "Failed to load extension \"a.b\": one".to_owned(),
748 "a.b: init failed: two".to_owned(),
749 "Failed to load extension \"c.d\": three".to_owned(),
750 "Failed to load extension \"e.f\": four".to_owned(),
751 ];
752 assert_eq!(
754 extension_suspects(&lines),
755 vec![
756 ("a.b".to_owned(), "init failed: two".to_owned()),
757 ("c.d".to_owned(), "three".to_owned()),
758 ("e.f".to_owned(), "four".to_owned()),
759 ]
760 );
761 }
762
763 #[test]
764 fn extension_suspects_ignores_unrelated_lines() {
765 let lines = vec![
766 "Duplicate extension id acme.notify ignored".to_owned(),
767 "Skipped extension package /tmp/x: bad manifest".to_owned(),
768 "No session found matching 'zzz'".to_owned(),
769 "Uppercase Id: not an extension".to_owned(),
770 ];
771 assert_eq!(extension_suspects(&lines).len(), 0);
772 }
773}