use crate::seed::{self, ShellIntentArgument, ShellIntentVocabulary, TerminalCommandVocabulary};
const REPORT_ISSUE_ACTION: &str = "formal-ai:report-issue";
pub(super) fn shell_command_for_task(prompt: &str) -> Option<String> {
let prompt = strip_balanced_outer_quotes(prompt.trim());
let vocab = seed::terminal_command_vocabulary();
let intent = intent_shell_command(prompt, &seed::shell_intent_vocabulary());
let listing = asks_for_directory_listing(prompt);
let web_search = crate::solver_handlers::web_search_query_for(prompt).is_some();
let semantic = listing.then(|| String::from("ls")).or(intent);
if let Some(command) = prefixed_shell_command(prompt, &vocab) {
let first = command
.split_whitespace()
.next()
.map(normalize_command_word);
let names_known_command = first
.as_ref()
.is_some_and(|first| vocab.shell_tokens.iter().any(|token| token == first));
let names_semantic_command = semantic.as_ref().is_some_and(|semantic| {
semantic
.split_whitespace()
.next()
.map(normalize_command_word)
== first
});
if names_semantic_command {
return semantic;
}
if names_known_command || (semantic.is_none() && !web_search) {
return Some(command);
}
}
semantic
.or_else(|| named_shell_command(prompt, &vocab))
.or_else(|| bare_shell_command(prompt, &vocab))
}
fn strip_balanced_outer_quotes(prompt: &str) -> &str {
for quote in ['"', '\'', '`'] {
if prompt.starts_with(quote) && prompt.ends_with(quote) && prompt.len() >= 2 {
return &prompt[1..prompt.len() - 1];
}
}
prompt
}
fn prefixed_shell_command(prompt: &str, vocab: &TerminalCommandVocabulary) -> Option<String> {
let prompt = prompt.trim();
let lower = prompt.to_lowercase();
if let Some(prefix) = vocab
.passthrough_prefixes
.iter()
.filter(|prefix| prefix_boundary(&lower, prefix))
.max_by_key(|prefix| prefix.chars().count())
{
let remainder = prompt.get(prefix.len()..)?.trim_start();
let remainder = remainder
.strip_prefix(':')
.unwrap_or(remainder)
.trim_start();
return (!remainder.is_empty()).then(|| strip_balanced_outer_quotes(remainder).to_owned());
}
None
}
fn bare_shell_command(prompt: &str, vocab: &TerminalCommandVocabulary) -> Option<String> {
let first = prompt.split_whitespace().next()?;
let command = normalize_command_word(first);
if crate::solver_handlers::web_search_query_for(prompt).is_some() {
return None;
}
let is_known = vocab
.bare_shell_tokens
.iter()
.any(|token| token == &command);
let words = prompt.split_whitespace().collect::<Vec<_>>();
(is_known && words[1..].iter().all(|word| !is_prose_word(word))).then(|| prompt.to_owned())
}
fn prefix_boundary(prompt: &str, prefix: &str) -> bool {
prompt.starts_with(prefix)
&& prompt
.get(prefix.len()..)
.and_then(|rest| rest.chars().next())
.is_some_and(|c| c.is_whitespace() || c == ':')
}
pub(super) fn code_search_query_for_task(prompt: &str) -> Option<String> {
let lower = prompt.to_lowercase();
let vocab = seed::shell_intent_vocabulary();
let intent_cues = vocab
.intents
.iter()
.filter(|intent| intent.command == "rg")
.flat_map(|intent| intent.cues.iter());
let capability_registry = seed::agentic_tool_capabilities();
let capability_cues = capability_registry
.iter()
.filter(|capability| capability.id == "grep")
.flat_map(|capability| capability.cues.iter());
let (_, cue) = intent_cues
.chain(capability_cues)
.filter(|cue| lower.contains(cue.as_str()))
.map(|cue| (cue.chars().count(), cue))
.max_by_key(|(length, _)| *length)?;
local_search_query(prompt, cue, &vocab)
}
fn intent_shell_command(prompt: &str, vocab: &ShellIntentVocabulary) -> Option<String> {
let lower = prompt.to_lowercase();
let (intent, cue) = vocab
.intents
.iter()
.filter(|intent| intent.command != REPORT_ISSUE_ACTION)
.flat_map(|intent| intent.cues.iter().map(move |cue| (intent, cue)))
.filter(|(intent, cue)| {
lower.contains(cue.as_str())
&& (intent.argument != ShellIntentArgument::SearchQuery
|| vocab
.local_search_scopes
.iter()
.any(|scope| lower.contains(scope)))
})
.max_by_key(|(_, cue)| cue.chars().count())?;
match intent.argument {
ShellIntentArgument::None => resolve_shell_command(&intent.command, vocab),
ShellIntentArgument::Path => {
path_argument(prompt).map(|arg| format!("{} {arg}", intent.command))
}
ShellIntentArgument::NameLead => name_lead_argument(prompt, &vocab.name_leads)
.map(|arg| format!("{} {arg}", intent.command)),
ShellIntentArgument::OnePath => {
path_arguments(prompt, cue, vocab, 1).map(|args| format!("{} {args}", intent.command))
}
ShellIntentArgument::TwoPaths => {
path_arguments(prompt, cue, vocab, 2).map(|args| format!("{} {args}", intent.command))
}
ShellIntentArgument::Remainder => remainder_argument(prompt, &lower, cue)
.map(|arg| format!("{} --fixed-strings -- '{arg}' .", intent.command)),
ShellIntentArgument::SearchQuery => local_search_query(prompt, cue, vocab)
.map(|arg| format!("{} --fixed-strings -- '{arg}' .", intent.command)),
}
}
fn path_arguments(
prompt: &str,
cue: &str,
vocab: &ShellIntentVocabulary,
count: usize,
) -> Option<String> {
let lower = prompt.to_lowercase();
let after_cue = lower
.find(cue)
.and_then(|start| prompt.get(start + cue.len()..))
.unwrap_or_default();
let mut arguments = collect_path_arguments(after_cue, "", vocab, count);
if arguments.len() < count {
arguments = collect_path_arguments(prompt, cue, vocab, count);
}
(arguments.len() == count).then(|| arguments.join(" "))
}
fn collect_path_arguments(
text: &str,
cue: &str,
vocab: &ShellIntentVocabulary,
count: usize,
) -> Vec<String> {
let cue = cue.to_lowercase();
let mut arguments = Vec::new();
for word in text.split_whitespace() {
let candidate = word
.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ';' | ':' | '!' | '?'));
let normalized = candidate.to_lowercase();
if candidate.is_empty()
|| !is_safe_path(candidate)
|| cue.split_whitespace().any(|part| part == normalized)
|| vocab
.argument_noise
.iter()
.any(|noise| noise == &normalized)
{
continue;
}
arguments.push(candidate.to_owned());
if arguments.len() == count {
break;
}
}
arguments
}
fn local_search_query(prompt: &str, cue: &str, vocab: &ShellIntentVocabulary) -> Option<String> {
let cue_words: Vec<String> = cue.split_whitespace().map(str::to_lowercase).collect();
let scope_words: Vec<String> = vocab
.local_search_scopes
.iter()
.flat_map(|scope| scope.split_whitespace().map(str::to_lowercase))
.collect();
let query = prompt
.split_whitespace()
.map(|word| {
word.trim_matches(|c: char| !c.is_alphanumeric() && !matches!(c, '_' | '-' | '.' | ':'))
})
.filter(|word| !word.is_empty())
.filter(|word| {
let lower = word.to_lowercase();
!cue_words.contains(&lower)
&& !scope_words.contains(&lower)
&& !vocab.argument_noise.contains(&lower)
})
.collect::<Vec<_>>()
.join(" ");
(!query.is_empty()
&& query.chars().all(|c| {
c.is_alphanumeric() || c.is_whitespace() || matches!(c, '_' | '-' | '.' | ':')
}))
.then_some(query)
}
fn resolve_shell_command(command: &str, vocab: &ShellIntentVocabulary) -> Option<String> {
let action = command.strip_prefix("formal-ai:workspace-");
if let Some(action) = action {
return workspace_command(action, vocab).map(str::to_owned);
}
Some(command.to_owned())
}
fn workspace_command<'a>(action: &str, vocab: &'a ShellIntentVocabulary) -> Option<&'a str> {
vocab
.workspace_commands
.iter()
.find(|commands| std::path::Path::new(&commands.marker).is_file())
.and_then(|commands| match action {
"test" => Some(commands.test.as_str()),
"install" => Some(commands.install.as_str()),
"build" => Some(commands.build.as_str()),
_ => None,
})
}
fn remainder_argument(prompt: &str, lower: &str, cue: &str) -> Option<String> {
let start = lower.find(cue)? + cue.len();
let remainder = prompt.get(start..)?.trim();
(!remainder.is_empty()
&& remainder.chars().all(|c| {
c.is_alphanumeric() || c.is_whitespace() || matches!(c, '_' | '-' | '.' | ':')
}))
.then(|| remainder.to_owned())
}
fn path_argument(prompt: &str) -> Option<String> {
prompt
.split(|c: char| c.is_whitespace())
.map(|word| word.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ';' | '?')))
.map(|word| word.trim_end_matches(['.', '!']))
.find(|token| {
let interior_dot = token.trim_matches('.').contains('.');
interior_dot && !token.contains("://") && is_safe_path(token)
})
.map(str::to_owned)
}
fn name_lead_argument(prompt: &str, name_leads: &[String]) -> Option<String> {
let words: Vec<&str> = prompt
.split(|c: char| c.is_whitespace())
.filter(|w| !w.is_empty())
.collect();
let lead_index = words.iter().position(|word| {
let normalized = word
.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase();
name_leads.iter().any(|lead| lead == &normalized)
})?;
let name = words
.get(lead_index + 1)?
.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ';' | '.' | '!' | '?' | ':'));
(!name.is_empty() && is_safe_path(name)).then(|| name.to_owned())
}
fn is_safe_path(token: &str) -> bool {
!token.starts_with('/')
&& !token.starts_with('-')
&& !token.split('/').any(|part| part == ".." || part.is_empty())
&& token
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'))
}
fn named_shell_command(prompt: &str, vocab: &TerminalCommandVocabulary) -> Option<String> {
let lower = prompt.to_ascii_lowercase();
let has_phrase = vocab.terminal_phrases.iter().any(|p| lower.contains(p));
let has_cjk_verb = vocab.cjk_run_verbs.iter().any(|v| lower.contains(v));
let words: Vec<&str> = prompt
.split(|c: char| c.is_whitespace())
.filter(|w| !w.is_empty())
.collect();
let is_run_verb = |word: &str| {
let normalized = normalize_command_word(word);
vocab.run_verbs.iter().any(|v| v == &normalized)
};
let is_shell_token = |word: &str| {
let normalized = normalize_command_word(word);
!normalized.is_empty() && vocab.shell_tokens.iter().any(|t| t == &normalized)
};
let has_verb = words.iter().any(|w| is_run_verb(w)) || has_cjk_verb;
for (index, word) in words.iter().enumerate() {
if index == 0 || !is_shell_token(word) {
continue;
}
if is_run_verb(words[index - 1]) {
return Some(collect_command(&words[index..]));
}
}
if has_verb || has_phrase {
if let Some(word) = words.iter().find(|w| is_shell_token(w)) {
return Some(normalize_command_word(word));
}
}
None
}
fn collect_command(words: &[&str]) -> String {
let mut parts = vec![normalize_command_word(words[0])];
for word in &words[1..] {
if is_prose_word(word) {
break;
}
let trimmed = word.trim_matches(|c: char| c == '`' || c == ',' || c == '.');
if trimmed.is_empty() {
break;
}
parts.push(trimmed.to_owned());
}
parts.join(" ")
}
fn normalize_command_word(word: &str) -> String {
word.trim_matches('`')
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect::<String>()
.to_ascii_lowercase()
}
fn is_prose_word(word: &str) -> bool {
const PROSE_WORDS: &[&str] = &[
"command",
"commands",
"to",
"in",
"into",
"on",
"the",
"a",
"an",
"and",
"then",
"please",
"for",
"of",
"that",
"which",
"so",
"this",
"these",
"those",
"here",
"there",
"me",
"us",
"you",
"it",
"from",
"at",
"with",
"will",
"would",
"can",
"could",
"should",
"using",
"via",
"inside",
"within",
"output",
"result",
"results",
"contents",
"content",
"directory",
"folder",
"folders",
"file",
"files",
"currently",
"again",
"also",
"just",
"now",
];
let normalized = word
.trim_matches(|c: char| !c.is_ascii_alphanumeric())
.to_ascii_lowercase();
PROSE_WORDS.contains(&normalized.as_str())
}
fn asks_for_directory_listing(prompt: &str) -> bool {
let lower = prompt.to_ascii_lowercase();
let asks_for_listing = contains_any(
&lower,
&[
"list files",
"list the files",
"list all files",
"list local files",
"list of files",
"list of all files",
"list directory",
"list the directory",
"list the contents",
"listing of files",
"directory listing",
"directory contents",
"folder contents",
"contents of this directory",
"contents of the current directory",
"contents of this folder",
"contents of the current folder",
"show files",
"show the files",
"show me the files",
"see the files",
],
);
let asks_which_files = contains_any(
&lower,
&[
"what files",
"which files",
"files are in",
"files in the",
"files in this",
"files in current",
"files in the current",
"files exist",
"files are here",
"files are there",
],
);
let local_scope = contains_any(
&lower,
&[
"here",
"current directory",
"working directory",
"current working directory",
"this directory",
"the directory",
"current folder",
"this folder",
"the folder",
"local files",
],
);
(asks_for_listing || asks_which_files) && local_scope
}
fn contains_any(text: &str, phrases: &[&str]) -> bool {
phrases.iter().any(|phrase| text.contains(phrase))
}