use crate::deliverable::{Deliverable, Milestone};
use crate::project::Project;
use crate::project_config::{ClearableField, ConfigEdit, ConfigField};
use super::ProjectCtlState;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeliverableView {
pub project_name: String,
pub deliverables: Vec<Deliverable>,
pub milestones: Vec<Milestone>,
pub scroll: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ConfigFormField {
pub original: Option<String>,
pub value: String,
}
impl ConfigFormField {
fn seeded(original: Option<String>) -> Self {
let value = original.clone().unwrap_or_default();
Self { original, value }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ConfigFormTags {
pub original: Vec<String>,
pub value: String,
}
impl ConfigFormTags {
fn seeded(tags: Vec<String>) -> Self {
let value = tags.join(", ");
Self {
original: tags,
value,
}
}
fn diff_edit(&self) -> Option<ConfigEdit> {
let current: Vec<String> = self
.value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let add: Vec<String> = current
.iter()
.filter(|t| !self.original.contains(t))
.cloned()
.collect();
let remove: Vec<String> = self
.original
.iter()
.filter(|t| !current.contains(t))
.cloned()
.collect();
if add.is_empty() && remove.is_empty() {
None
} else {
Some(ConfigEdit::Tags { add, remove })
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConfigFormFocus {
#[default]
DefaultBranch,
Description,
StackHint,
GhUser,
Tags,
}
impl ConfigFormFocus {
pub fn next(self) -> Self {
match self {
Self::DefaultBranch => Self::Description,
Self::Description => Self::StackHint,
Self::StackHint => Self::GhUser,
Self::GhUser => Self::Tags,
Self::Tags => Self::DefaultBranch,
}
}
pub fn prev(self) -> Self {
match self {
Self::DefaultBranch => Self::Tags,
Self::Description => Self::DefaultBranch,
Self::StackHint => Self::Description,
Self::GhUser => Self::StackHint,
Self::Tags => Self::GhUser,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigFormView {
pub project_name: String,
pub default_branch: ConfigFormField,
pub description: ConfigFormField,
pub stack_hint: ConfigFormField,
pub gh_user: ConfigFormField,
pub tags: ConfigFormTags,
pub focus: ConfigFormFocus,
pub error: Option<String>,
}
impl ConfigFormView {
fn from_project(project: &Project) -> Self {
Self {
project_name: project.name.clone(),
default_branch: ConfigFormField::seeded(Some(project.default_branch.clone())),
description: ConfigFormField::seeded(project.description.clone()),
stack_hint: ConfigFormField::seeded(project.stack_hint.clone()),
gh_user: ConfigFormField::seeded(project.gh_user.clone()),
tags: ConfigFormTags::seeded(project.tags.clone()),
focus: ConfigFormFocus::default(),
error: None,
}
}
pub fn diff_edits(&self) -> Vec<ConfigEdit> {
let mut edits = Vec::new();
let branch = self.default_branch.value.trim();
if !branch.is_empty() && Some(branch) != self.default_branch.original.as_deref() {
edits.push(ConfigEdit::Set(
ConfigField::DefaultBranch,
branch.to_string(),
));
}
push_clearable_edit(
&mut edits,
ConfigField::Description,
ClearableField::Description,
&self.description,
);
push_clearable_edit(
&mut edits,
ConfigField::StackHint,
ClearableField::StackHint,
&self.stack_hint,
);
push_clearable_edit(
&mut edits,
ConfigField::GhUser,
ClearableField::GhUser,
&self.gh_user,
);
if let Some(tags_edit) = self.tags.diff_edit() {
edits.push(tags_edit);
}
edits
}
fn push_char(&mut self, c: char) {
self.focused_buffer_mut().push(c);
}
fn backspace(&mut self) {
self.focused_buffer_mut().pop();
}
fn focused_buffer_mut(&mut self) -> &mut String {
match self.focus {
ConfigFormFocus::DefaultBranch => &mut self.default_branch.value,
ConfigFormFocus::Description => &mut self.description.value,
ConfigFormFocus::StackHint => &mut self.stack_hint.value,
ConfigFormFocus::GhUser => &mut self.gh_user.value,
ConfigFormFocus::Tags => &mut self.tags.value,
}
}
}
fn push_clearable_edit(
edits: &mut Vec<ConfigEdit>,
set_field: ConfigField,
clear_field: ClearableField,
field: &ConfigFormField,
) {
let trimmed = field.value.trim();
let original = field.original.as_deref().unwrap_or("");
if trimmed == original {
return;
}
if trimmed.is_empty() {
edits.push(ConfigEdit::Unset(clear_field));
} else {
edits.push(ConfigEdit::Set(set_field, trimmed.to_string()));
}
}
impl ProjectCtlState {
pub fn open_deliverable_view(&mut self, project_name: impl Into<String>) {
self.deliverable_view = Some(DeliverableView {
project_name: project_name.into(),
deliverables: self.deliverables.clone().unwrap_or_default(),
milestones: Vec::new(),
scroll: 0,
});
}
pub fn close_deliverable_view(&mut self) {
self.deliverable_view = None;
}
pub fn scroll_deliverable_view(&mut self, delta: i16) {
if let Some(view) = &mut self.deliverable_view {
view.scroll = view.scroll.saturating_add_signed(delta);
}
}
pub fn open_config_form(&mut self, project_name: impl Into<String>) {
let name = project_name.into();
match self.projects_full.get(&name) {
Some(project) => self.config_form = Some(ConfigFormView::from_project(project)),
None => self.set_notice(format!(
"no config loaded yet for '{name}' — try again after the next refresh"
)),
}
}
pub fn close_config_form(&mut self) {
self.config_form = None;
}
pub fn config_form_focus_next(&mut self) {
if let Some(form) = &mut self.config_form {
form.focus = form.focus.next();
}
}
pub fn config_form_focus_prev(&mut self) {
if let Some(form) = &mut self.config_form {
form.focus = form.focus.prev();
}
}
pub fn config_form_push_char(&mut self, c: char) {
if let Some(form) = &mut self.config_form {
form.push_char(c);
}
}
pub fn config_form_backspace(&mut self) {
if let Some(form) = &mut self.config_form {
form.backspace();
}
}
pub fn set_config_form_error(&mut self, msg: impl Into<String>) {
if let Some(form) = &mut self.config_form {
form.error = Some(msg.into());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn project() -> Project {
Project {
name: "widget".to_string(),
repo_url: "https://github.com/acme/widget".to_string(),
default_branch: "main".to_string(),
stack_hint: Some("rust".to_string()),
tags: vec!["backend".to_string(), "oss".to_string()],
description: Some("the widget".to_string()),
gh_user: Some("acme-bot".to_string()),
gh_account: None,
github: None,
commit_name: None,
commit_email: None,
worktree: None,
}
}
#[test]
fn config_form_seeds_from_project() {
let form = ConfigFormView::from_project(&project());
assert_eq!(form.default_branch.value, "main");
assert_eq!(form.description.value, "the widget");
assert_eq!(form.stack_hint.value, "rust");
assert_eq!(form.gh_user.value, "acme-bot");
assert_eq!(form.tags.value, "backend, oss");
assert!(form.diff_edits().is_empty(), "unedited form has no diff");
}
#[test]
fn config_form_diff_detects_set_and_unset() {
let mut form = ConfigFormView::from_project(&project());
form.stack_hint.value = "python".to_string();
form.description.value = String::new(); let edits = form.diff_edits();
assert!(edits.contains(&ConfigEdit::Set(
ConfigField::StackHint,
"python".to_string()
)));
assert!(edits.contains(&ConfigEdit::Unset(ClearableField::Description)));
}
#[test]
fn config_form_blanking_default_branch_is_a_noop_not_a_set() {
let mut form = ConfigFormView::from_project(&project());
form.default_branch.value = " ".to_string();
assert!(
form.diff_edits().is_empty(),
"default_branch has no clear story — a blanked buffer must be skipped, not sent"
);
}
#[test]
fn config_form_tags_diff_computes_add_and_remove() {
let mut form = ConfigFormView::from_project(&project());
form.tags.value = "backend, ml".to_string(); let edits = form.diff_edits();
assert_eq!(
edits,
vec![ConfigEdit::Tags {
add: vec!["ml".to_string()],
remove: vec!["oss".to_string()],
}]
);
}
#[test]
fn config_form_focus_cycles_through_all_five_rows() {
let mut focus = ConfigFormFocus::default();
let mut seen = vec![focus];
for _ in 0..4 {
focus = focus.next();
seen.push(focus);
}
assert_eq!(focus.next(), ConfigFormFocus::DefaultBranch);
assert_eq!(seen.len(), 5);
assert_eq!(seen[4].next(), ConfigFormFocus::DefaultBranch);
for f in seen {
assert_eq!(f.next().prev(), f);
}
}
#[test]
fn state_open_close_config_form() {
let mut state = ProjectCtlState {
projects_full: [("widget".to_string(), project())].into_iter().collect(),
..Default::default()
};
state.open_config_form("widget");
assert!(state.config_form.is_some());
assert_eq!(state.config_form.as_ref().unwrap().project_name, "widget");
state.close_config_form();
assert!(state.config_form.is_none());
}
#[test]
fn state_open_config_form_without_a_loaded_project_sets_notice() {
let mut state = ProjectCtlState::default();
state.open_config_form("ghost");
assert!(state.config_form.is_none());
assert!(state.notice.as_deref().unwrap_or("").contains("ghost"));
}
#[test]
fn state_push_char_and_backspace_edit_the_focused_field() {
let mut state = ProjectCtlState {
projects_full: [("widget".to_string(), project())].into_iter().collect(),
..Default::default()
};
state.open_config_form("widget");
state.config_form_push_char('!');
assert_eq!(
state.config_form.as_ref().unwrap().default_branch.value,
"main!"
);
state.config_form_backspace();
assert_eq!(
state.config_form.as_ref().unwrap().default_branch.value,
"main"
);
state.config_form_focus_next();
state.config_form_push_char('x');
assert_eq!(
state.config_form.as_ref().unwrap().description.value,
"the widgetx"
);
}
#[test]
fn state_set_config_form_error_is_inline_not_a_notice() {
let mut state = ProjectCtlState {
projects_full: [("widget".to_string(), project())].into_iter().collect(),
..Default::default()
};
state.open_config_form("widget");
state.set_config_form_error("project name is the identity key");
assert_eq!(
state.config_form.as_ref().unwrap().error.as_deref(),
Some("project name is the identity key")
);
assert!(state.config_form.is_some());
}
fn shared_case_baseline_project() -> Project {
Project {
name: "widget".to_string(),
repo_url: "https://github.com/acme/widget".to_string(),
default_branch: "baseline-branch".to_string(),
stack_hint: Some("baseline-hint".to_string()),
tags: vec!["oss".to_string()],
description: Some("baseline desc".to_string()),
gh_user: Some("baseline-user".to_string()),
gh_account: None,
github: None,
commit_name: None,
commit_email: None,
worktree: None,
}
}
#[test]
fn config_form_shared_cases_match_tui_diff() {
use crate::project_config::{config_edit_cases, merge_patch_args};
for case in config_edit_cases() {
let mut form = ConfigFormView::from_project(&shared_case_baseline_project());
apply_case_edit_to_form(&mut form, &case.edit);
let edits = form.diff_edits();
let args = merge_patch_args(&edits);
crate::project_config::assert_matches_case(&args, &case);
}
}
fn apply_case_edit_to_form(form: &mut ConfigFormView, edit: &ConfigEdit) {
match edit {
ConfigEdit::Set(ConfigField::DefaultBranch, v) => form.default_branch.value = v.clone(),
ConfigEdit::Set(ConfigField::Description, v) => form.description.value = v.clone(),
ConfigEdit::Set(ConfigField::StackHint, v) => form.stack_hint.value = v.clone(),
ConfigEdit::Set(ConfigField::GhUser, v) => form.gh_user.value = v.clone(),
ConfigEdit::Set(ConfigField::Worktree, _) => {}
ConfigEdit::Unset(ClearableField::Description) => form.description.value.clear(),
ConfigEdit::Unset(ClearableField::StackHint) => form.stack_hint.value.clear(),
ConfigEdit::Unset(ClearableField::GhUser) => form.gh_user.value.clear(),
ConfigEdit::Tags { add, remove } => {
let mut current = form.tags.original.clone();
current.retain(|t| !remove.contains(t));
current.extend(add.iter().cloned());
form.tags.value = current.join(", ");
}
}
}
}