use std::io::{IsTerminal, stdin, stdout};
use std::path::PathBuf;
use inquire::validator::{StringValidator, Validation};
use inquire::{Confirm, InquireError, MultiSelect, Select, Text};
use sara_core::error::SaraError;
use sara_core::graph::{KnowledgeGraph, KnowledgeGraphBuilder};
use sara_core::model::{FieldName, ItemType, TraceabilityLinks};
use sara_core::repository::parse_repositories;
use thiserror::Error;
use crate::output::{OutputConfig, print_error};
#[derive(Debug, Default)]
pub struct PrefilledFields {
pub file: Option<PathBuf>,
pub item_type: Option<ItemType>,
pub id: Option<String>,
pub name: Option<String>,
pub description: Option<String>,
pub refines: Vec<String>,
pub derives_from: Vec<String>,
pub satisfies: Vec<String>,
pub depends_on: Vec<String>,
pub specification: Option<String>,
pub platform: Option<String>,
pub deciders: Vec<String>,
pub justifies: Vec<String>,
}
pub struct InteractiveSession<'a> {
pub graph: Option<KnowledgeGraph>,
pub prefilled: PrefilledFields,
pub repositories: &'a [PathBuf],
pub output: &'a OutputConfig,
}
#[derive(Debug)]
pub struct InteractiveInput {
pub file: PathBuf,
pub item_type: ItemType,
pub id: String,
pub name: String,
pub description: Option<String>,
pub traceability: TraceabilityLinks,
pub type_specific: TypeSpecificInput,
}
#[derive(Debug, Default)]
pub enum TypeSpecificInput {
#[default]
None,
Requirement { specification: Option<String> },
SystemArchitecture { platform: Option<String> },
Adr { deciders: Vec<String> },
}
#[derive(Debug, Error)]
pub enum PromptError {
#[error("Interactive mode requires a terminal. Use --type <TYPE> to specify the item type.")]
NonInteractiveTerminal,
#[error(transparent)]
MissingParent(#[from] SaraError),
#[error("User cancelled")]
Cancelled,
#[error("Prompt error: {0}")]
InquireError(#[from] InquireError),
}
#[derive(Debug, Clone)]
pub struct SelectOption {
pub id: String,
pub name: String,
}
impl std::fmt::Display for SelectOption {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} - {}", self.id, self.name)
}
}
pub fn require_tty() -> Result<(), PromptError> {
if !stdin().is_terminal() || !stdout().is_terminal() {
return Err(PromptError::NonInteractiveTerminal);
}
Ok(())
}
#[derive(Clone)]
struct IdValidator;
impl StringValidator for IdValidator {
fn validate(&self, input: &str) -> Result<Validation, inquire::CustomUserError> {
if input.is_empty() {
return Ok(Validation::Invalid("ID cannot be empty".into()));
}
if input
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
Ok(Validation::Valid)
} else {
Ok(Validation::Invalid(
"ID must contain only letters, numbers, hyphens, and underscores".into(),
))
}
}
}
#[derive(Clone)]
struct NameValidator;
impl StringValidator for NameValidator {
fn validate(&self, input: &str) -> Result<Validation, inquire::CustomUserError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(Validation::Invalid("Name is required".into()));
}
if trimmed.len() > 200 {
return Ok(Validation::Invalid(
"Name must be 200 characters or less".into(),
));
}
Ok(Validation::Valid)
}
}
fn prompt_item_type(prefilled: Option<ItemType>) -> Result<ItemType, PromptError> {
if let Some(item_type) = prefilled {
return Ok(item_type);
}
let options: Vec<ItemType> = ItemType::all().to_vec();
let selection = Select::new("Select item type:", options)
.with_help_message("Use arrow keys to navigate, Enter to select")
.prompt()?;
Ok(selection)
}
pub fn prompt_name(
prefilled: Option<&String>,
default: Option<&str>,
) -> Result<String, PromptError> {
if let Some(name) = prefilled {
return Ok(name.clone());
}
let mut prompt = Text::new("Item name:")
.with_validator(NameValidator)
.with_help_message("Enter a human-readable name for this item");
if let Some(def) = default {
prompt = prompt.with_default(def);
}
let name = prompt.prompt()?;
Ok(name.trim().to_string())
}
pub fn prompt_description(
prefilled: Option<&String>,
default: Option<&str>,
) -> Result<Option<String>, PromptError> {
if let Some(desc) = prefilled {
return Ok(Some(desc.clone()));
}
let mut prompt = Text::new("Description (optional):")
.with_help_message("Brief summary of the item (press Enter to skip)");
if let Some(def) = default {
prompt = prompt.with_default(def);
}
let desc = prompt.prompt()?;
let trimmed = desc.trim();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed.to_string()))
}
}
fn prompt_identifier(
item_type: ItemType,
graph: Option<&KnowledgeGraph>,
prefilled: Option<&String>,
) -> Result<String, PromptError> {
if let Some(id) = prefilled {
return Ok(id.clone());
}
let suggested = item_type.suggest_next_id(graph);
let id = Text::new("Identifier:")
.with_default(&suggested)
.with_validator(IdValidator)
.with_help_message("Unique identifier (suggested default shown)")
.prompt()?;
Ok(id.trim().to_string())
}
fn get_items_of_type(
graph: Option<&KnowledgeGraph>,
item_type: ItemType,
exclude_id: Option<&str>,
) -> Vec<SelectOption> {
graph
.map(|g| {
g.items()
.filter(|item| item.item_type == item_type)
.filter(|item| exclude_id.is_none_or(|id| item.id.as_str() != id))
.map(|item| SelectOption {
id: item.id.as_str().to_string(),
name: item.name.clone(),
})
.collect()
})
.unwrap_or_default()
}
pub type PreselectedTraceability = TraceabilityLinks;
fn compute_default_indices(options: &[SelectOption], preselected: &[String]) -> Vec<usize> {
options
.iter()
.enumerate()
.filter(|(_, opt)| preselected.contains(&opt.id))
.map(|(i, _)| i)
.collect()
}
#[derive(Debug, Clone, Copy)]
enum TraceabilityKind {
Refines,
DerivesFrom,
Satisfies,
DependsOn,
Justifies,
}
impl TraceabilityKind {
fn from_field(field: FieldName) -> Self {
match field {
FieldName::Refines => Self::Refines,
FieldName::DerivesFrom => Self::DerivesFrom,
FieldName::Satisfies => Self::Satisfies,
FieldName::DependsOn => Self::DependsOn,
FieldName::Justifies => Self::Justifies,
_ => Self::Refines, }
}
}
struct TraceabilityPromptConfig {
kind: TraceabilityKind,
target_type: ItemType,
prompt_message: String,
}
fn get_traceability_prompt_configs(item_type: ItemType) -> Vec<TraceabilityPromptConfig> {
item_type
.traceability_configs()
.into_iter()
.map(|config| {
let kind = TraceabilityKind::from_field(config.relationship_field);
let prompt_message = format!(
"Select {} this {} {}:",
config.target_type.display_name(),
item_type.display_name(),
config.relationship_field.as_str().replace('_', " ")
);
TraceabilityPromptConfig {
kind,
target_type: config.target_type,
prompt_message,
}
})
.collect()
}
fn get_prefilled_for_kind(prefilled: &PrefilledFields, kind: TraceabilityKind) -> &[String] {
match kind {
TraceabilityKind::Refines => &prefilled.refines,
TraceabilityKind::DerivesFrom => &prefilled.derives_from,
TraceabilityKind::Satisfies => &prefilled.satisfies,
TraceabilityKind::DependsOn => &prefilled.depends_on,
TraceabilityKind::Justifies => &prefilled.justifies,
}
}
fn get_preselected_for_kind(
preselected: Option<&PreselectedTraceability>,
kind: TraceabilityKind,
) -> Vec<String> {
preselected
.map(|p| match kind {
TraceabilityKind::Refines => p.refines.clone(),
TraceabilityKind::DerivesFrom => p.derives_from.clone(),
TraceabilityKind::Satisfies => p.satisfies.clone(),
TraceabilityKind::DependsOn => p.depends_on.clone(),
TraceabilityKind::Justifies => p.justifies.clone(),
})
.unwrap_or_default()
}
fn prompt_target_selection(
options: Vec<SelectOption>,
prompt_message: &str,
preselected_ids: &[String],
) -> Result<Vec<String>, PromptError> {
if options.is_empty() {
return Ok(Vec::new());
}
let defaults = compute_default_indices(&options, preselected_ids);
let selected = MultiSelect::new(prompt_message, options)
.with_help_message("Space to select, Enter to confirm")
.with_default(&defaults)
.prompt()?;
Ok(selected.into_iter().map(|s| s.id).collect())
}
fn apply_selection_to_input(
input: &mut TraceabilityLinks,
kind: TraceabilityKind,
ids: Vec<String>,
) {
match kind {
TraceabilityKind::Refines => input.refines.extend(ids),
TraceabilityKind::DerivesFrom => input.derives_from.extend(ids),
TraceabilityKind::Satisfies => input.satisfies.extend(ids),
TraceabilityKind::DependsOn => input.depends_on.extend(ids),
TraceabilityKind::Justifies => input.justifies.extend(ids),
}
}
pub fn prompt_traceability(
item_type: ItemType,
graph: Option<&KnowledgeGraph>,
prefilled: &PrefilledFields,
preselected: Option<&PreselectedTraceability>,
exclude_id: Option<&str>,
) -> Result<TraceabilityLinks, PromptError> {
let mut input = TraceabilityLinks::default();
let configs = get_traceability_prompt_configs(item_type);
if configs.is_empty() {
return Ok(input);
}
for config in configs {
let prefilled_values = get_prefilled_for_kind(prefilled, config.kind);
if !prefilled_values.is_empty() {
apply_selection_to_input(&mut input, config.kind, prefilled_values.to_vec());
continue;
}
let options = get_items_of_type(graph, config.target_type, exclude_id);
let preselected_ids = get_preselected_for_kind(preselected, config.kind);
let selected = prompt_target_selection(options, &config.prompt_message, &preselected_ids)?;
apply_selection_to_input(&mut input, config.kind, selected);
}
Ok(input)
}
pub fn prompt_specification(
item_type: ItemType,
prefilled: Option<&String>,
default: Option<&str>,
) -> Result<Option<String>, PromptError> {
if !item_type.requires_specification() {
return Ok(None);
}
if let Some(spec) = prefilled {
return Ok(Some(spec.clone()));
}
let mut prompt = Text::new("Specification:")
.with_help_message("Enter the SHALL statement (e.g., 'The system SHALL...')")
.with_validator(NameValidator);
if let Some(def) = default {
prompt = prompt.with_default(def);
}
let spec = prompt.prompt()?;
Ok(Some(spec.trim().to_string()))
}
pub fn prompt_platform(
item_type: ItemType,
prefilled: Option<&String>,
default: Option<&str>,
) -> Result<Option<String>, PromptError> {
if item_type != ItemType::SystemArchitecture {
return Ok(None);
}
if let Some(platform) = prefilled {
return Ok(Some(platform.clone()));
}
let mut prompt = Text::new("Target platform (optional):")
.with_help_message("e.g., AWS, STM32, Linux (press Enter to skip)");
if let Some(def) = default {
prompt = prompt.with_default(def);
}
let platform = prompt.prompt()?;
let trimmed = platform.trim();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed.to_string()))
}
}
pub fn prompt_deciders(
item_type: ItemType,
prefilled: &[String],
default: &[String],
) -> Result<Vec<String>, PromptError> {
if item_type != ItemType::ArchitectureDecisionRecord {
return Ok(Vec::new());
}
if !prefilled.is_empty() {
return Ok(prefilled.to_vec());
}
let mut deciders: Vec<String> = default.to_vec();
loop {
let prompt_msg = if deciders.is_empty() {
"Decider name (press Enter to skip):"
} else {
"Additional decider (press Enter to finish):"
};
let input = Text::new(prompt_msg)
.with_help_message("Person responsible for this decision")
.prompt()?;
let trimmed = input.trim();
if trimmed.is_empty() {
break;
}
deciders.push(trimmed.to_string());
}
Ok(deciders)
}
fn prompt_confirmation(input: &InteractiveInput) -> Result<bool, PromptError> {
let summary = build_confirmation_summary(input);
println!("{}", summary);
let confirmed = Confirm::new("Create document?")
.with_default(true)
.prompt()?;
Ok(confirmed)
}
fn build_confirmation_summary(input: &InteractiveInput) -> String {
let description = input
.description
.as_ref()
.map(|d| format!("\n Description: {}", d))
.unwrap_or_default();
let refines = if input.traceability.refines.is_empty() {
String::new()
} else {
format!("\n Refines: {}", input.traceability.refines.join(", "))
};
let derives_from = if input.traceability.derives_from.is_empty() {
String::new()
} else {
format!(
"\n Derives from: {}",
input.traceability.derives_from.join(", ")
)
};
let satisfies = if input.traceability.satisfies.is_empty() {
String::new()
} else {
format!("\n Satisfies: {}", input.traceability.satisfies.join(", "))
};
let depends_on = if input.traceability.depends_on.is_empty() {
String::new()
} else {
format!(
"\n Depends on: {}",
input.traceability.depends_on.join(", ")
)
};
let justifies = if input.traceability.justifies.is_empty() {
String::new()
} else {
format!("\n Justifies: {}", input.traceability.justifies.join(", "))
};
let type_specific_info = match &input.type_specific {
TypeSpecificInput::None => String::new(),
TypeSpecificInput::Requirement { specification } => specification
.as_ref()
.map(|s| format!("\n Specification: {}", s))
.unwrap_or_default(),
TypeSpecificInput::SystemArchitecture { platform } => platform
.as_ref()
.map(|p| format!("\n Platform: {}", p))
.unwrap_or_default(),
TypeSpecificInput::Adr { deciders } => {
if deciders.is_empty() {
String::new()
} else {
format!("\n Deciders: {}", deciders.join(", "))
}
}
};
format!(
"\n\
\x20 Summary:\n\
\x20 ────────────────────────────────────\n\
\x20 Type: {}\n\
\x20 ID: {}\n\
\x20 Name: {}\n\
\x20 File: {}{}{}{}{}{}{}{}\n",
input.item_type.display_name(),
input.id,
input.name,
input.file.display(),
description,
refines,
derives_from,
satisfies,
depends_on,
justifies,
type_specific_info,
)
}
fn prompt_file(prefilled: Option<&PathBuf>) -> Result<PathBuf, PromptError> {
if let Some(file) = prefilled {
return Ok(file.clone());
}
let file = Text::new("File path:")
.with_help_message("Path for the new document (e.g., docs/SOL-001.md)")
.with_validator(|input: &str| {
let trimmed = input.trim();
if trimmed.is_empty() {
Ok(Validation::Invalid("File path is required".into()))
} else {
Ok(Validation::Valid)
}
})
.prompt()?;
Ok(PathBuf::from(file.trim()))
}
pub fn run_interactive_session(
session: &mut InteractiveSession<'_>,
) -> Result<InteractiveInput, PromptError> {
require_tty()?;
ensure_graph_loaded(session);
let item_type = prompt_item_type(session.prefilled.item_type)?;
if let Some(graph) = &session.graph {
graph.check_parent_exists(item_type)?;
}
let input = collect_item_input(session, item_type)?;
confirm_creation(&input)?;
Ok(input)
}
fn ensure_graph_loaded(session: &mut InteractiveSession<'_>) {
if session.graph.is_some() || session.repositories.is_empty() {
return;
}
match build_graph_from_repositories(session.repositories) {
Ok(graph) => {
session.graph = Some(graph);
}
Err(msg) => {
print_error(session.output, msg);
}
}
}
fn build_graph_from_repositories(repositories: &[PathBuf]) -> Result<KnowledgeGraph, &'static str> {
let items = parse_repositories(repositories).map_err(|e| {
tracing::warn!("Parse error: {}", e);
"Failed to parse repositories"
})?;
KnowledgeGraphBuilder::new()
.add_items(items)
.build()
.map_err(|e| {
tracing::warn!("Graph build error: {}", e);
"Failed to build graph"
})
}
fn collect_item_input(
session: &InteractiveSession<'_>,
item_type: ItemType,
) -> Result<InteractiveInput, PromptError> {
let name = prompt_name(session.prefilled.name.as_ref(), None)?;
let id = prompt_identifier(
item_type,
session.graph.as_ref(),
session.prefilled.id.as_ref(),
)?;
let description = prompt_description(session.prefilled.description.as_ref(), None)?;
let traceability = prompt_traceability(
item_type,
session.graph.as_ref(),
&session.prefilled,
None,
Some(&id),
)?;
let type_specific = collect_type_specific_input(session, item_type)?;
let file = prompt_file(session.prefilled.file.as_ref())?;
Ok(InteractiveInput {
file,
item_type,
id,
name,
description,
traceability,
type_specific,
})
}
fn collect_type_specific_input(
session: &InteractiveSession<'_>,
item_type: ItemType,
) -> Result<TypeSpecificInput, PromptError> {
match item_type {
ItemType::SystemRequirement
| ItemType::SoftwareRequirement
| ItemType::HardwareRequirement => {
let specification =
prompt_specification(item_type, session.prefilled.specification.as_ref(), None)?;
Ok(TypeSpecificInput::Requirement { specification })
}
ItemType::SystemArchitecture => {
let platform = prompt_platform(item_type, session.prefilled.platform.as_ref(), None)?;
Ok(TypeSpecificInput::SystemArchitecture { platform })
}
ItemType::ArchitectureDecisionRecord => {
let deciders = prompt_deciders(item_type, &session.prefilled.deciders, &[])?;
Ok(TypeSpecificInput::Adr { deciders })
}
_ => Ok(TypeSpecificInput::None),
}
}
fn confirm_creation(input: &InteractiveInput) -> Result<(), PromptError> {
if prompt_confirmation(input)? {
Ok(())
} else {
Err(PromptError::Cancelled)
}
}
pub fn handle_interactive_result(
result: Result<InteractiveInput, PromptError>,
config: &OutputConfig,
) -> Result<Option<InteractiveInput>, PromptError> {
match result {
Ok(input) => Ok(Some(input)),
Err(PromptError::Cancelled) => {
println!();
println!("Cancelled. No file was created.");
Ok(None)
}
Err(PromptError::InquireError(InquireError::OperationInterrupted)) => {
println!();
println!("Cancelled. No file was created.");
Ok(None)
}
Err(PromptError::NonInteractiveTerminal) => {
print_error(
config,
"Interactive mode requires a terminal. Use --type <TYPE> to specify the item type.",
);
Err(PromptError::NonInteractiveTerminal)
}
Err(PromptError::MissingParent(err)) => {
print_error(config, &err.to_string());
Err(PromptError::MissingParent(err))
}
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_suggest_next_id_no_graph() {
let id = ItemType::Solution.suggest_next_id(None);
assert!(id.starts_with("SOL-"));
}
#[test]
fn test_id_validator_valid() {
let validator = IdValidator;
assert!(matches!(
validator.validate("SOL-001"),
Ok(Validation::Valid)
));
assert!(matches!(
validator.validate("UC_002"),
Ok(Validation::Valid)
));
}
#[test]
fn test_id_validator_invalid() {
let validator = IdValidator;
assert!(matches!(validator.validate(""), Ok(Validation::Invalid(_))));
assert!(matches!(
validator.validate("SOL 001"),
Ok(Validation::Invalid(_))
));
}
#[test]
fn test_name_validator_valid() {
let validator = NameValidator;
assert!(matches!(
validator.validate("Test Name"),
Ok(Validation::Valid)
));
}
#[test]
fn test_name_validator_empty() {
let validator = NameValidator;
assert!(matches!(validator.validate(""), Ok(Validation::Invalid(_))));
assert!(matches!(
validator.validate(" "),
Ok(Validation::Invalid(_))
));
}
#[test]
fn test_required_parent_type() {
assert_eq!(ItemType::Solution.required_parent_type(), None);
assert_eq!(
ItemType::UseCase.required_parent_type(),
Some(ItemType::Solution)
);
assert_eq!(
ItemType::Scenario.required_parent_type(),
Some(ItemType::UseCase)
);
}
#[test]
fn test_traceability_field() {
assert_eq!(ItemType::Solution.traceability_field(), None);
assert_eq!(
ItemType::UseCase.traceability_field(),
Some(FieldName::Refines)
);
assert_eq!(
ItemType::SystemRequirement.traceability_field(),
Some(FieldName::DerivesFrom)
);
assert_eq!(
ItemType::SystemArchitecture.traceability_field(),
Some(FieldName::Satisfies)
);
}
}