diff --git a/demo/console/component-edit.tape b/demo/console/component-edit.tape new file mode 100644 index 00000000..4103b432 --- /dev/null +++ b/demo/console/component-edit.tape @@ -0,0 +1,43 @@ +Output images/component-defn-console-edit.gif + +Require lula +Set FontSize 14 +Set Width 1850 +Set Height 925 +Set Framerate 24 +Set Padding 5 + +Hide +Type "lula console -f ./src/test/unit/common/oscal/valid-multi-component-validations.yaml" Enter +Sleep 1s +Show + +# Navigate and select a control +Right +Sleep 500ms +Right +Sleep 500ms +Right +Sleep 1s +Enter +Sleep 1s + +# Navigate to description +Right +Sleep 500ms +Right +Sleep 500ms +Type "e" +Sleep 1s + +# Add new line and write some text +Ctrl+E +Sleep 500ms +Type "Here is some text" +Sleep 500ms +Enter +Sleep 2s + +# Show save dialog +Ctrl+S +Sleep 2s diff --git a/demo/console/component-read.tape b/demo/console/component-read.tape new file mode 100644 index 00000000..6b337526 --- /dev/null +++ b/demo/console/component-read.tape @@ -0,0 +1,60 @@ +Output images/component-defn-console-read.gif + +Require lula +Set FontSize 14 +Set Width 1850 +Set Height 925 +Set Framerate 24 +Set Padding 5 + +Hide +Type "lula console -f ./src/test/unit/common/oscal/valid-multi-component-validations.yaml" Enter +Sleep 1s +Show + +# Show the component picker +Right +Sleep 500ms +Enter +Sleep 500ms +Down +Sleep 500ms +Up +Sleep 1s +Enter +Sleep 1s + +# Show the framework picker +Right +Sleep 500ms +Enter +Sleep 1s +Enter +Sleep 1s + +# Navigate to a control and toggle though fields +Right +Sleep 500ms +Down +Sleep 500ms +Enter +Sleep 1s +Right +Sleep 500ms +Down +Sleep 500ms +Down +Sleep 500ms +Up +Sleep 500ms +Up +Sleep 500ms +Right +Sleep 1s +Right +Sleep 2s + +# Quit + confirm +Ctrl+c +Sleep 2s +Enter diff --git a/docs/console/component-definition.md b/docs/console/component-definition.md index 77347282..15eddc41 100644 --- a/docs/console/component-definition.md +++ b/docs/console/component-definition.md @@ -1,6 +1,6 @@ # Component Definition -The Component Definition view currently allows for a read-only experience of the OSCAL Component Definition model. The view is tailored to the usage of the Component Definition in the context of Lula, and is not intended to be a comprehensive view of the model. +The Component Definition view currently allows for a read and limited write experience of the OSCAL Component Definition model. The view is tailored to the usage of the Component Definition in the context of Lula, and is not intended to be a comprehensive view of the model. ## Usage @@ -8,10 +8,11 @@ To view an OSCAL Component Definition model in the Console: ```shell lula console -f /path/to/oscal-component.yaml ``` +The `oscal-component.yaml` will need to be a valid OSCAL model - to use with the Component Definition view, it must contain the `component-definition` top level key. ## Keys -The Component Definition model responds to the following keys for navigation and interaction: +The Component Definition model responds to the following keys for navigation and interaction (some widgets have additional key response, see respective help views for more information): | Key | Description | |-----|-------------| @@ -21,12 +22,22 @@ The Component Definition model responds to the following keys for navigation and | `shift+tab` | Tab left between models | | `←/h` | Navigate left in model| | `→/l` | Navigate right in model | -| `↑/k` | Move up in list OR scroll up | -| `↓/j` | Move down in list OR scroll down | +| `↑/k` | Move up in list OR scroll up in panel | +| `↓/j` | Move down in list OR scroll up in panel | | `/` | Filter list | | `↳` | Select item | +| `e` | Edit available fields (remarks and description) | +| `ctrl+s` | Save changes (Note: this will overwrite the original file) | | `esc` | Cancel | -## View +During console viewing, the top-right corner will display the help keys availble in the context of the selected widget. When an overlay is open, the help keys will be displayed in the overlay. -component definition console \ No newline at end of file +## Views + +### Read-Only Navigation + +component definition console read + +### Editing Remarks and Description + +component definition console edit diff --git a/go.mod b/go.mod index 56ffb9df..00ae59b6 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,8 @@ require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.1.1 github.com/charmbracelet/lipgloss v0.13.0 - github.com/charmbracelet/x/exp/teatest v0.0.0-20240918160051-227168dc0568 + github.com/charmbracelet/x/exp/teatest v0.0.0-20240919170804-a4978c8e603a + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/defenseunicorns/go-oscal v0.6.0 github.com/defenseunicorns/pkg/helpers v1.1.3 github.com/hashicorp/go-version v1.7.0 @@ -47,12 +48,11 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect - github.com/charmbracelet/x/ansi v0.2.3 // indirect - github.com/charmbracelet/x/exp/golden v0.0.0-20240904165849-e8e43e13f84b // indirect + github.com/charmbracelet/x/ansi v0.3.2 // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20240919170804-a4978c8e603a // indirect github.com/charmbracelet/x/term v0.2.0 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/daviddengcn/go-colortext v1.0.0 // indirect github.com/distribution/reference v0.5.0 // indirect github.com/emicklei/go-restful/v3 v3.11.2 // indirect diff --git a/go.sum b/go.sum index 95fa15ec..cd7f98cf 100644 --- a/go.sum +++ b/go.sum @@ -60,10 +60,16 @@ github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY= github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/exp/golden v0.0.0-20240904165849-e8e43e13f84b h1:RgxGYl8ddy5ozXS6ZXcfkMynPzwReLeS54Xy1PBXoNo= -github.com/charmbracelet/x/exp/golden v0.0.0-20240904165849-e8e43e13f84b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY= +github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20240919170804-a4978c8e603a h1:IUy+N6nKpGfijckOe8KGnAQwBUT6xz63n3tbb0Gy8aY= +github.com/charmbracelet/x/exp/golden v0.0.0-20240919170804-a4978c8e603a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/teatest v0.0.0-20240918160051-227168dc0568 h1:MWPyZsxZMe/oKhkt5j34zAba/2nXOxWuf3CvQqO8SDA= github.com/charmbracelet/x/exp/teatest v0.0.0-20240918160051-227168dc0568/go.mod h1:NDRRSMP6bZbCs4jyc4i1/4UG4M+0PEiQdpivQgD0Mio= +github.com/charmbracelet/x/exp/teatest v0.0.0-20240919170804-a4978c8e603a h1:sS42HbmCab8rCehUwNO/bQEZQoJ6GavhZyO+245mBwA= +github.com/charmbracelet/x/exp/teatest v0.0.0-20240919170804-a4978c8e603a/go.mod h1:NDRRSMP6bZbCs4jyc4i1/4UG4M+0PEiQdpivQgD0Mio= github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= diff --git a/images/component-defn-console-edit.gif b/images/component-defn-console-edit.gif new file mode 100644 index 00000000..b0ce5dc7 Binary files /dev/null and b/images/component-defn-console-edit.gif differ diff --git a/images/component-defn-console-read.gif b/images/component-defn-console-read.gif new file mode 100644 index 00000000..4d26dc02 Binary files /dev/null and b/images/component-defn-console-read.gif differ diff --git a/images/component-defn-console.gif b/images/component-defn-console.gif deleted file mode 100644 index 4ff94f24..00000000 Binary files a/images/component-defn-console.gif and /dev/null differ diff --git a/src/cmd/console/console.go b/src/cmd/console/console.go index b9b322a8..0e66e92f 100644 --- a/src/cmd/console/console.go +++ b/src/cmd/console/console.go @@ -46,15 +46,16 @@ var consoleCmd = &cobra.Command{ // Add debugging // TODO: need to integrate with the log file handled by messages + var dumpFile *os.File if message.GetLogLevel() == message.DebugLevel { - f, err := tea.LogToFile("debug.log", "debug") + dumpFile, err = os.OpenFile("debug.log", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) if err != nil { message.Fatalf(err, err.Error()) } - defer f.Close() + defer dumpFile.Close() } - p := tea.NewProgram(tui.NewOSCALModel(oscalModel), tea.WithAltScreen(), tea.WithMouseCellMotion()) + p := tea.NewProgram(tui.NewOSCALModel(oscalModel, opts.InputFile, dumpFile), tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { message.Fatalf(err, err.Error()) diff --git a/src/internal/testhelpers/testhelpers.go b/src/internal/testhelpers/testhelpers.go new file mode 100644 index 00000000..936ab224 --- /dev/null +++ b/src/internal/testhelpers/testhelpers.go @@ -0,0 +1,34 @@ +package testhelpers + +import ( + "fmt" + "os" + "testing" + + oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" + "github.com/defenseunicorns/lula/src/pkg/common/oscal" +) + +func OscalFromPath(t *testing.T, path string) *oscalTypes_1_1_2.OscalCompleteSchema { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("error reading file: %v", err) + } + oscalModel, err := oscal.NewOscalModel(data) + if err != nil { + t.Fatalf("error creating oscal model from file: %v", err) + } + + return oscalModel +} + +func CreateTempFile(t *testing.T, ext string) *os.File { + t.Helper() + tempFile, err := os.CreateTemp("", fmt.Sprintf("tmp-*.%s", ext)) + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + + return tempFile +} diff --git a/src/internal/tui/assessment_results/assessment-results.go b/src/internal/tui/assessment_results/assessment-results.go index 3125a916..b7c02064 100644 --- a/src/internal/tui/assessment_results/assessment-results.go +++ b/src/internal/tui/assessment_results/assessment-results.go @@ -5,7 +5,7 @@ import ( "regexp" "strings" - "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" blist "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -75,9 +75,13 @@ func NewAssessmentResultsModel(assessmentResults *oscalTypes_1_1_2.AssessmentRes observationSummary := viewport.New(width, height) observationSummary.Style = common.PanelStyle + help := common.NewHelpModel(false) + help.OneLine = true + help.ShortHelp = []key.Binding{assessmentHotkeys.Help} + return Model{ keys: assessmentHotkeys, - help: help.New(), + help: help, results: results, resultsPicker: resultsPicker, selectedResult: selectedResult, @@ -183,7 +187,7 @@ func (m Model) View() string { func (m Model) mainView() string { // Add help panel at the top left helpStyle := common.HelpStyle(m.width) - helpView := helpStyle.Render(m.help.View(m.keys)) + helpView := helpStyle.Render(m.help.View()) // Add viewport styles focusedViewport := common.PanelStyle.BorderForeground(common.Focused) @@ -252,9 +256,9 @@ func (m Model) mainView() string { } func (m Model) updateViewportContent(resultType string) string { - helpStyle := common.HelpStyle(pickerWidth) - helpView := helpStyle.Render(help.New().View(common.PickerHotkeys)) - + // TODO: refactor this to use the PiickerModel + help := common.NewHelpModel(true) + help.ShortHelp = common.ShortHelpPicker s := strings.Builder{} s.WriteString(fmt.Sprintf("Select a result to %s:\n\n", resultType)) @@ -268,7 +272,7 @@ func (m Model) updateViewportContent(resultType string) string { s.WriteString("\n") } - return lipgloss.JoinVertical(lipgloss.Top, helpView, s.String()) + return lipgloss.JoinVertical(lipgloss.Top, s.String(), help.View()) } func (m Model) renderSummary() string { diff --git a/src/internal/tui/assessment_results/keys.go b/src/internal/tui/assessment_results/keys.go index 8422b665..e5725889 100644 --- a/src/internal/tui/assessment_results/keys.go +++ b/src/internal/tui/assessment_results/keys.go @@ -21,8 +21,8 @@ type keys struct { } var assessmentHotkeys = keys{ - Quit: common.CommonHotkeys.Quit, - Help: common.CommonHotkeys.Help, + Quit: common.CommonKeys.Quit, + Help: common.CommonKeys.Help, Validate: key.NewBinding( key.WithKeys("v"), key.WithHelp("v", "validate"), @@ -31,26 +31,14 @@ var assessmentHotkeys = keys{ key.WithKeys("e"), key.WithHelp("e", "evaluate"), ), - Confirm: common.PickerHotkeys.Confirm, - Cancel: common.PickerHotkeys.Cancel, - Navigation: key.NewBinding( - key.WithKeys("left", "h", "right", "l"), - key.WithHelp("←/h, →/l", "navigation"), - ), - NavigateLeft: key.NewBinding( - key.WithKeys("left", "h"), - key.WithHelp("←/h", "navigate left"), - ), - NavigateRight: key.NewBinding( - key.WithKeys("right", "l"), - key.WithHelp("→/l", "navigate right"), - ), - SwitchModels: key.NewBinding( - key.WithKeys("tab", "shift+tab"), - key.WithHelp("tab/shift+tab", "switch models"), - ), - Up: common.PickerHotkeys.Up, - Down: common.PickerHotkeys.Down, + Confirm: common.CommonKeys.Confirm, + Cancel: common.CommonKeys.Cancel, + Navigation: common.CommonKeys.Navigation, + NavigateLeft: common.CommonKeys.NavigateLeft, + NavigateRight: common.CommonKeys.NavigateRight, + SwitchModels: common.CommonKeys.NavigateModels, + Up: common.PickerKeys.Up, + Down: common.PickerKeys.Down, } func (k keys) ShortHelp() []key.Binding { diff --git a/src/internal/tui/assessment_results/types.go b/src/internal/tui/assessment_results/types.go index 950c3775..109f352e 100644 --- a/src/internal/tui/assessment_results/types.go +++ b/src/internal/tui/assessment_results/types.go @@ -1,7 +1,6 @@ package assessmentresults import ( - "github.com/charmbracelet/bubbles/help" blist "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/viewport" oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" @@ -10,7 +9,7 @@ import ( type Model struct { open bool - help help.Model + help common.HelpModel keys keys focus focus inResultOverlay bool diff --git a/src/internal/tui/common/common.go b/src/internal/tui/common/common.go index e435184d..cfc4f675 100644 --- a/src/internal/tui/common/common.go +++ b/src/internal/tui/common/common.go @@ -1,9 +1,14 @@ package common import ( + "fmt" + "os" + "github.com/charmbracelet/bubbles/key" blist "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" + "github.com/davecgh/go-spew/spew" "github.com/mattn/go-runewidth" ) @@ -13,6 +18,8 @@ const ( DefaultHeight = 60 ) +var DumpFile *os.File + func TruncateText(text string, width int) string { if runewidth.StringWidth(text) <= width { return text @@ -32,7 +39,7 @@ func NewUnfocusedDelegate() blist.DefaultDelegate { d.Styles.SelectedDesc = d.Styles.NormalDesc d.ShortHelpFunc = func() []key.Binding { - return []key.Binding{ListHotkeys.Confirm, ListHotkeys.Help} + return []key.Binding{ListKeys.Confirm, ListKeys.Help} } return d @@ -42,7 +49,7 @@ func NewUnfocusedHighlightDelegate() blist.DefaultDelegate { d := blist.NewDefaultDelegate() d.ShortHelpFunc = func() []key.Binding { - return []key.Binding{ListHotkeys.Confirm, ListHotkeys.Help} + return []key.Binding{ListKeys.Confirm, ListKeys.Help} } return d @@ -52,7 +59,7 @@ func NewFocusedDelegate() blist.DefaultDelegate { d := blist.NewDefaultDelegate() d.ShortHelpFunc = func() []key.Binding { - return []key.Binding{ListHotkeys.Confirm, ListHotkeys.Help} + return []key.Binding{ListKeys.Confirm, ListKeys.Help} } return d @@ -76,8 +83,6 @@ func UnfocusedListKeyMap() blist.KeyMap { func FocusedPanelKeyMap() viewport.KeyMap { km := viewport.DefaultKeyMap() - // km.Up.SetEnabled(true) - // km.Down.SetEnabled(true) return km } @@ -87,3 +92,33 @@ func UnfocusedPanelKeyMap() viewport.KeyMap { return km } + +func FocusedTextAreaKeyMap() textarea.KeyMap { + km := textarea.DefaultKeyMap + + km.InsertNewline = key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("ctrl+e", "insert newline"), + ) + + return km +} + +func UnfocusedTextAreaKeyMap() textarea.KeyMap { + km := textarea.KeyMap{} + + return km +} + +func PrintToLog(format string, a ...any) { + if DumpFile != nil { + out := fmt.Sprintf(format, a...) + spew.Fprintln(DumpFile, out) + } +} + +func DumpToLog(msg ...any) { + if DumpFile != nil { + spew.Fdump(DumpFile, msg) + } +} diff --git a/src/internal/tui/common/editor.go b/src/internal/tui/common/editor.go new file mode 100644 index 00000000..c55aadd7 --- /dev/null +++ b/src/internal/tui/common/editor.go @@ -0,0 +1,48 @@ +package common + +import "time" + +type EditType int + +const ( + EditTypeAdd EditType = iota + EditTypeUpdate + EditTypeDelete +) + +type Editor struct { + EditsByPath map[string][]*Edit +} + +type Edit struct { + EditType + Path string + Value map[string]interface{} // could this handle any type? + Timestamp time.Time +} + +func NewEditor() *Editor { + return &Editor{ + EditsByPath: make(map[string][]*Edit), + } +} + +// ResetEditor resets the editor - runs after data is saved +func (e *Editor) ResetEditor() { + e.EditsByPath = make(map[string][]*Edit) +} + +// IsEmpty checks if any edits have been made +func (e *Editor) IsEmpty() bool { + return len(e.EditsByPath) == 0 +} + +func (e *Editor) AddUpdateEdit(path string, value map[string]interface{}) { + e.EditsByPath[path] = append(e.EditsByPath[path], + &Edit{ + EditType: EditTypeUpdate, + Path: path, + Value: value, + Timestamp: time.Now(), + }) +} diff --git a/src/internal/tui/common/help.go b/src/internal/tui/common/help.go new file mode 100644 index 00000000..75fed24e --- /dev/null +++ b/src/internal/tui/common/help.go @@ -0,0 +1,230 @@ +package common + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Note: This package has been adapted from the original help package in github.com/charmbracelet/bubbles + +type HelpType int + +const ( + HelpTypeMain HelpType = iota + HelpTypeEdit + HelpTypeList + HelpTypeSelect + HelpTypePanel + HelpTypeQuit +) + +// Styles is a set of available style definitions for the Help bubble. +type HelpStyles struct { + Ellipsis lipgloss.Style + + // Styling for the short help + ShortKey lipgloss.Style + ShortDesc lipgloss.Style + ShortSeparator lipgloss.Style + + // Styling for the full help + FullKey lipgloss.Style + FullDesc lipgloss.Style + FullSeparator lipgloss.Style +} + +func NewStyles(active bool) HelpStyles { + if active { + return HelpStyles{ + ShortKey: ActiveKeyStyle, + ShortDesc: ActiveDescStyle, + ShortSeparator: ActiveSepStyle, + Ellipsis: ActiveSepStyle, + FullKey: ActiveKeyStyle, + FullDesc: ActiveDescStyle, + FullSeparator: ActiveSepStyle, + } + } + return HelpStyles{ + ShortKey: KeyStyle, + ShortDesc: DescStyle, + ShortSeparator: SepStyle, + Ellipsis: SepStyle, + FullKey: KeyStyle, + FullDesc: DescStyle, + FullSeparator: SepStyle, + } +} + +// HelpModel contains the state of the help view. +type HelpModel struct { + Width int + + ShowAll bool // if true, render the "full" help menu + OneLine bool + + ShortHelp []key.Binding + FullHelpOneLine []key.Binding + FullHelp [][]key.Binding + + ShortSeparator string + FullSeparator string + + // The symbol we use in the short help when help items have been truncated + // due to width. Periods of ellipsis by default. + Ellipsis string + + Styles HelpStyles +} + +// New creates a new help view with some useful defaults. +func NewHelpModel(active bool) HelpModel { + return HelpModel{ + ShortSeparator: " • ", + FullSeparator: " ", + Ellipsis: "…", + Styles: NewStyles(active), + } +} + +// Update helps satisfy the Bubble Tea Model interface. It's a no-op. +func (m HelpModel) Update(_ tea.Msg) (HelpModel, tea.Cmd) { + return m, nil +} + +// View renders the help view's current state. +func (m HelpModel) View() string { + if m.ShowAll { + if m.OneLine { + return m.SingleLineHelpView(m.FullHelpOneLine) + } + return m.FullHelpView(m.FullHelp) + } + return m.SingleLineHelpView(m.ShortHelp) +} + +// SingleLineHelpView renders a single line help view from a slice of keybindings. +// If the line is longer than the maximum width it will be gracefully +// truncated, showing only as many help items as possible. +func (m HelpModel) SingleLineHelpView(bindings []key.Binding) string { + if len(bindings) == 0 { + return "" + } + + var b strings.Builder + var totalWidth int + separator := m.Styles.ShortSeparator.Inline(true).Render(m.ShortSeparator) + + for i, kb := range bindings { + if !kb.Enabled() { + continue + } + + var sep string + if totalWidth > 0 && i < len(bindings) { + sep = separator + } + + str := sep + + m.Styles.ShortKey.Inline(true).Render(kb.Help().Key) + " " + + m.Styles.ShortDesc.Inline(true).Render(kb.Help().Desc) + + w := lipgloss.Width(str) + + // If adding this help item would go over the available width, stop + // drawing. + if m.Width > 0 && totalWidth+w > m.Width { + // Although if there's room for an ellipsis, print that. + tail := " " + m.Styles.Ellipsis.Inline(true).Render(m.Ellipsis) + tailWidth := lipgloss.Width(tail) + + if totalWidth+tailWidth < m.Width { + b.WriteString(tail) + } + + break + } + + totalWidth += w + b.WriteString(str) + } + + return b.String() +} + +// FullHelpView renders help columns from a slice of key binding slices. Each +// top level slice entry renders into a column. +func (m HelpModel) FullHelpView(groups [][]key.Binding) string { + if len(groups) == 0 { + return "" + } + + // Linter note: at this time we don't think it's worth the additional + // code complexity involved in preallocating this slice. + //nolint:prealloc + var ( + out []string + + totalWidth int + sep = m.Styles.FullSeparator.Render(m.FullSeparator) + sepWidth = lipgloss.Width(sep) + ) + + // Iterate over groups to build columns + for i, group := range groups { + if group == nil || !shouldRenderColumn(group) { + continue + } + + var ( + keys []string + descriptions []string + ) + + // Separate keys and descriptions into different slices + for _, kb := range group { + if !kb.Enabled() { + continue + } + keys = append(keys, kb.Help().Key) + descriptions = append(descriptions, kb.Help().Desc) + } + + col := lipgloss.JoinHorizontal(lipgloss.Top, + m.Styles.FullKey.Render(strings.Join(keys, "\n")), + m.Styles.FullKey.Render(" "), + m.Styles.FullDesc.Render(strings.Join(descriptions, "\n")), + ) + + // Column + totalWidth += lipgloss.Width(col) + if m.Width > 0 && totalWidth > m.Width { + break + } + + out = append(out, col) + + // Separator + if i < len(group)-1 { + totalWidth += sepWidth + if m.Width > 0 && totalWidth > m.Width { + break + } + out = append(out, sep) + } + } + + return lipgloss.JoinHorizontal(lipgloss.Top, out...) +} + +func shouldRenderColumn(b []key.Binding) (ok bool) { + for _, v := range b { + if v.Enabled() { + return true + } + } + return false +} diff --git a/src/internal/tui/common/keys.go b/src/internal/tui/common/keys.go index 346d388b..335ace6d 100644 --- a/src/internal/tui/common/keys.go +++ b/src/internal/tui/common/keys.go @@ -5,17 +5,29 @@ import ( ) type Keys struct { - Quit key.Binding - Confirm key.Binding - ModelLeft key.Binding - ModelRight key.Binding - Help key.Binding + Quit key.Binding + Help key.Binding + ModelLeft key.Binding + ModelRight key.Binding + NavigateModels key.Binding + NavigateLeft key.Binding + NavigateRight key.Binding + Navigation key.Binding + Confirm key.Binding + Select key.Binding + Cancel key.Binding + Up key.Binding + Down key.Binding + Filter key.Binding + Edit key.Binding + Save key.Binding + Newline key.Binding } -var CommonHotkeys = Keys{ +var CommonKeys = Keys{ Quit: key.NewBinding( - key.WithKeys("q", "ctrl+c"), - key.WithHelp("q", "quit"), + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "quit"), ), Help: key.NewBinding( key.WithKeys("?"), @@ -29,6 +41,42 @@ var CommonHotkeys = Keys{ key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "model left"), ), + NavigateModels: key.NewBinding( + key.WithKeys("tab", "shift+tab"), + key.WithHelp("tab/shift+tab", "switch models"), + ), + Navigation: key.NewBinding( + key.WithKeys("left", "h", "right", "l"), + key.WithHelp("←/h, →/l", "navigation"), + ), + NavigateLeft: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "navigate left"), + ), + NavigateRight: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "navigate right"), + ), + Confirm: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("↳", "confirm"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("↳", "select"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + Edit: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "edit"), + ), + Save: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "save"), + ), } func ContainsKey(v string, a []string) string { @@ -43,13 +91,14 @@ func ContainsKey(v string, a []string) string { type listKeys struct { Up key.Binding Down key.Binding - Slash key.Binding + Filter key.Binding Confirm key.Binding - Escape key.Binding + Select key.Binding + Cancel key.Binding Help key.Binding } -var ListHotkeys = listKeys{ +var ListKeys = listKeys{ Up: key.NewBinding( key.WithKeys("up", "k"), key.WithHelp("↑/k", "move up"), @@ -58,15 +107,19 @@ var ListHotkeys = listKeys{ key.WithKeys("down", "j"), key.WithHelp("↓/j", "move down"), ), - Slash: key.NewBinding( + Filter: key.NewBinding( key.WithKeys("/"), key.WithHelp("/", "filter"), ), Confirm: key.NewBinding( key.WithKeys("enter"), - key.WithHelp("enter", "select"), + key.WithHelp("↳", "confirm"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("↳", "select"), ), - Escape: key.NewBinding( + Cancel: key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "cancel"), ), @@ -76,24 +129,26 @@ var ListHotkeys = listKeys{ ), } -func (k listKeys) ShortHelp() []key.Binding { - return []key.Binding{k.Up, k.Down, k.Help} -} - -func (k listKeys) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.Up, k.Down}, {k.Slash, k.Confirm}, {k.Escape, k.Help}, +var ( + ShortHelpList = []key.Binding{ + ListKeys.Select, ListKeys.Up, ListKeys.Down, ListKeys.Filter, ListKeys.Help, } -} + FullHelpListOneLine = []key.Binding{ + ListKeys.Select, ListKeys.Up, ListKeys.Down, ListKeys.Filter, ListKeys.Cancel, ListKeys.Help, + } + FullHelpList = [][]key.Binding{ + {ListKeys.Select}, {ListKeys.Up}, {ListKeys.Down}, {ListKeys.Filter}, {ListKeys.Cancel}, {ListKeys.Help}, + } +) type pickerKeys struct { - Up key.Binding - Down key.Binding - Confirm key.Binding - Cancel key.Binding + Up key.Binding + Down key.Binding + Select key.Binding + Cancel key.Binding } -var PickerHotkeys = pickerKeys{ +var PickerKeys = pickerKeys{ Up: key.NewBinding( key.WithKeys("up", "k"), key.WithHelp("↑/k", "move up"), @@ -102,7 +157,7 @@ var PickerHotkeys = pickerKeys{ key.WithKeys("down", "j"), key.WithHelp("↓/j", "move down"), ), - Confirm: key.NewBinding( + Select: key.NewBinding( key.WithKeys("enter"), key.WithHelp("↳", "select"), ), @@ -112,12 +167,53 @@ var PickerHotkeys = pickerKeys{ ), } -func (k pickerKeys) ShortHelp() []key.Binding { - return []key.Binding{k.Up, k.Down, k.Confirm, k.Cancel} +var ( + ShortHelpPicker = []key.Binding{ + PickerKeys.Up, PickerKeys.Down, PickerKeys.Select, PickerKeys.Cancel, + } + FullHelpPickerOneLine = []key.Binding{ + PickerKeys.Up, PickerKeys.Down, PickerKeys.Select, PickerKeys.Cancel, + } + FullHelpPicker = [][]key.Binding{ + {PickerKeys.Up}, {PickerKeys.Down}, {PickerKeys.Select}, {PickerKeys.Cancel}, + } +) + +// Implemented for +type editorKeys struct { + Confirm key.Binding + NewLine key.Binding + DeleteWord key.Binding + Cancel key.Binding } -func (k pickerKeys) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.Up}, {k.Down}, {k.Confirm}, {k.Cancel}, - } +var EditKeys = editorKeys{ + Confirm: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + NewLine: key.NewBinding( + key.WithKeys("ctrl+e"), + key.WithHelp("ctrl+e", "new line"), + ), + DeleteWord: key.NewBinding( + key.WithKeys("alt+backspace"), + key.WithHelp("alt+backspace", "delete word"), + ), + Cancel: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), } + +var ( + ShortHelpEditing = []key.Binding{ + EditKeys.Confirm, EditKeys.NewLine, EditKeys.DeleteWord, EditKeys.Cancel, + } + FullHelpEditingOneLine = []key.Binding{ + EditKeys.Confirm, EditKeys.NewLine, EditKeys.DeleteWord, EditKeys.Cancel, + } + FullHelpEditing = [][]key.Binding{ + {EditKeys.Confirm}, {EditKeys.NewLine}, {EditKeys.DeleteWord}, {EditKeys.Cancel}, + } +) diff --git a/src/internal/tui/common/picker.go b/src/internal/tui/common/picker.go new file mode 100644 index 00000000..c8db3b00 --- /dev/null +++ b/src/internal/tui/common/picker.go @@ -0,0 +1,119 @@ +package common + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type PickerKind string + +const ( + pickerWidth = 80 + pickerHeight = 20 +) + +type PickerOpenMsg struct { + Kind PickerKind +} +type PickerItemSelected struct { + Selected int + From PickerKind +} + +type PickerModel struct { + Open bool + items []string + selected int + title string + kind PickerKind + help HelpModel + viewer viewport.Model +} + +func NewPickerModel(title string, kind PickerKind, items []string, initSelected int) PickerModel { + help := NewHelpModel(true) + help.ShortHelp = ShortHelpPicker + return PickerModel{ + items: items, + selected: initSelected, + title: title, + kind: kind, + help: help, + viewer: viewport.New(pickerWidth, pickerHeight), + } +} + +func (m PickerModel) Init() tea.Cmd { + return nil +} + +func (m PickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + + switch msg := msg.(type) { + case tea.KeyMsg: + k := msg.String() + if m.Open { + switch k { + case ContainsKey(k, PickerKeys.Up.Keys()): + m.selected-- + if m.selected < 0 { + m.selected = len(m.items) - 1 + } + + case ContainsKey(k, PickerKeys.Down.Keys()): + m.selected++ + if m.selected >= len(m.items) { + m.selected = 0 + } + + case ContainsKey(k, PickerKeys.Select.Keys()): + m.Open = false + return m, func() tea.Msg { + return PickerItemSelected{ + Selected: m.selected, + From: m.kind, + } + } + + case ContainsKey(k, PickerKeys.Cancel.Keys()): + m.Open = false + } + } + case PickerOpenMsg: + if msg.Kind == m.kind { + m.Open = true + } + } + return m, nil +} + +func (m PickerModel) View() string { + itemHeight := len(m.items) + 2 + if itemHeight > pickerHeight { + itemHeight = pickerHeight + } + + overlayPickerStyle := OverlayStyle. + Width(pickerWidth). + Height(itemHeight) + + s := strings.Builder{} + s.WriteString(fmt.Sprintf("%s\n\n", m.title)) + + for i, itm := range m.items { + if m.selected == i { + s.WriteString("(•) ") + } else { + s.WriteString("( ) ") + } + s.WriteString(itm) + s.WriteString("\n") + } + // m.viewer.SetContent(s.String()) + pickerContent := lipgloss.JoinVertical(lipgloss.Top, s.String(), m.help.View()) + return overlayPickerStyle.Render(pickerContent) +} diff --git a/src/internal/tui/common/popup.go b/src/internal/tui/common/popup.go new file mode 100644 index 00000000..9892ad47 --- /dev/null +++ b/src/internal/tui/common/popup.go @@ -0,0 +1,87 @@ +package common + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + popupWidth = 40 + popupHeight = 10 +) + +type PopupMsg struct { + Title string + Content string + Warning string +} +type PopupClose struct{} + +type PopupFailMsg struct { + Err error +} + +type PopupModel struct { + Open bool + Title string + Content string + Warning string + Help HelpModel +} + +func NewPopupModel(title, content string, helpKeys []key.Binding) PopupModel { + help := NewHelpModel(true) + help.ShortHelp = helpKeys + return PopupModel{ + Help: help, + Title: title, + Content: content, + } +} + +func (m *PopupModel) UpdateText(title, content, warning string) { + m.Title = title + m.Content = content + m.Warning = warning +} + +func (m PopupModel) Init() tea.Cmd { + return nil +} + +func (m PopupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + PrintToLog("in popup update") + DumpToLog(msg) + switch msg := msg.(type) { + case PopupMsg: + PrintToLog("in popup msg") + m.UpdateText(msg.Title, msg.Content, msg.Warning) + + case PopupClose: + m.Open = false + } + return m, nil +} + +func (m PopupModel) View() string { + PrintToLog("in popup view") + popupStyle := OverlayWarnStyle. + Width(popupWidth). + Height(popupHeight) + + content := strings.Builder{} + content.WriteString(fmt.Sprintf("%s\n", m.Title)) + if m.Content != "" { + content.WriteString(fmt.Sprintf("\n%s\n", m.Content)) + } + if m.Warning != "" { + content.WriteString(fmt.Sprintf("\n⚠️ %s ⚠️\n", m.Warning)) + } + + popupContent := lipgloss.JoinVertical(lipgloss.Top, content.String(), m.Help.View()) + return popupStyle.Render(popupContent) +} diff --git a/src/internal/tui/common/save.go b/src/internal/tui/common/save.go new file mode 100644 index 00000000..34708b49 --- /dev/null +++ b/src/internal/tui/common/save.go @@ -0,0 +1,125 @@ +package common + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type SaveStartMsg struct{} +type SaveSuccessMsg struct{} +type SaveNoChangeMsg struct{} +type SaveFailMsg struct { + Err error +} +type SaveCloseAndResetMsg struct{} +type SaveModelMsg struct { + InQuitWorkflow bool +} + +type SaveModel struct { + Open bool + Save bool + FilePath string + Title string + Content string + Warning string + RenderedDuringQuit bool + Help HelpModel +} + +func NewSaveModel(filepath string) SaveModel { + help := NewHelpModel(true) + help.ShortHelp = []key.Binding{CommonKeys.Confirm, CommonKeys.Cancel} + return SaveModel{ + Help: help, + Title: "Save OSCAL Model", + Content: fmt.Sprintf("Save changes to %s?", filepath), + FilePath: filepath, + } +} + +func (m SaveModel) Init() tea.Cmd { + return nil +} + +func (m SaveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + + switch msg := msg.(type) { + case tea.KeyMsg: + k := msg.String() + if m.Open { + switch k { + case ContainsKey(k, CommonKeys.Confirm.Keys()): + if m.Save { + var cmds []tea.Cmd + cmds = append(cmds, func() tea.Msg { + return SaveStartMsg{} + }) + cmds = append(cmds, func() tea.Msg { + return SaveModelMsg{ + InQuitWorkflow: m.RenderedDuringQuit, + } + }) + + return m, tea.Sequence(cmds...) + } else { + m.Open = false + return m, nil + } + + case ContainsKey(k, CommonKeys.Cancel.Keys()): + m.Open = false + m.Save = false + } + } + + case SaveStartMsg: + m.Title = "Saving..." + m.Content = fmt.Sprintf("Saving to: %s", m.FilePath) + m.Warning = "" + m.Help.ShortHelp = []key.Binding{} + + case SaveFailMsg: + m.Title = "Error saving model" + m.Content = msg.Err.Error() + m.Warning = "Changes not saved" + m.Save = false + + case SaveSuccessMsg: + m.Title = "Model saved!" + m.Content = fmt.Sprintf("Model saved to %s", m.FilePath) + m.Warning = "" + m.Save = false + + case SaveCloseAndResetMsg: + m.Title = "Save OSCAL Model" + m.Content = fmt.Sprintf("Save changes to %s?", m.FilePath) + m.Open = false + m.Save = false + m.Help.ShortHelp = []key.Binding{CommonKeys.Confirm, CommonKeys.Cancel} + } + return m, nil +} + +func (m SaveModel) View() string { + PrintToLog("in popup view") + popupStyle := OverlayWarnStyle. + Width(popupWidth). + Height(popupHeight) + + content := strings.Builder{} + content.WriteString(fmt.Sprintf("%s\n", m.Title)) + if m.Content != "" { + content.WriteString(fmt.Sprintf("\n%s\n", m.Content)) + } + if m.Warning != "" { + content.WriteString(fmt.Sprintf("\n⚠️ %s ⚠️\n", m.Warning)) + } + + popupContent := lipgloss.JoinVertical(lipgloss.Top, content.String(), m.Help.View()) + return popupStyle.Render(popupContent) +} diff --git a/src/internal/tui/common/styles.go b/src/internal/tui/common/styles.go index da299a9d..91a2cef8 100644 --- a/src/internal/tui/common/styles.go +++ b/src/internal/tui/common/styles.go @@ -26,9 +26,18 @@ var ( Subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} Highlight = lipgloss.AdaptiveColor{Light: "#6d26fc", Dark: "#7D56F4"} + Highlight2 = lipgloss.AdaptiveColor{Light: "#8f58fc", Dark: "#8f6ef0"} Focused = lipgloss.AdaptiveColor{Light: "#8378ab", Dark: "#bfb2eb"} Special = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"} Background = lipgloss.AdaptiveColor{Light: "#c5c6c7", Dark: "#333436"} + Warning = lipgloss.AdaptiveColor{Light: "#FFA100", Dark: "#F9A431"} + + HelpKey = lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"} + HelpDesc = lipgloss.AdaptiveColor{Light: "#B2B2B2", Dark: "#4A4A4A"} + HelpSep = lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"} + ActiveHelpKey = Highlight2 + ActiveHelpDesc = Highlight + ActiveHelpSep = Highlight // Tabs @@ -95,12 +104,25 @@ var ( BorderForeground(Focused). Padding(1, 1) + OverlayWarnStyle = lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder(), true). + BorderForeground(Warning). + Padding(1, 1) + BoxStyle = lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), true). BorderForeground(Highlight). Padding(1, 2). Margin(1). Width(30) + + // Help + KeyStyle = lipgloss.NewStyle().Foreground(HelpKey) + DescStyle = lipgloss.NewStyle().Foreground(HelpDesc) + SepStyle = lipgloss.NewStyle().Foreground(HelpSep) + ActiveKeyStyle = lipgloss.NewStyle().Foreground(ActiveHelpKey) + ActiveDescStyle = lipgloss.NewStyle().Foreground(ActiveHelpDesc) + ActiveSepStyle = lipgloss.NewStyle().Foreground(ActiveHelpSep) ) func HeaderView(titleText string, width int, focusColor lipgloss.AdaptiveColor) string { diff --git a/src/internal/tui/common/warn.go b/src/internal/tui/common/warn.go deleted file mode 100644 index 4d38d961..00000000 --- a/src/internal/tui/common/warn.go +++ /dev/null @@ -1,19 +0,0 @@ -package common - -type warnType int - -type WarnModal struct { - open bool - warnType warnType - title string - content string -} - -// func (m model) warnModalRender() string { -// title := m.warnModel.title -// content := m.warnModel.content -// confirm := modalConfirm.Render(" (" + hotkeys.Confirm[0] + ") Confirm ") -// cancel := modalCancel.Render(" (" + hotkeys.Quit[0] + ") Cancel ") -// tip := confirm + lipgloss.NewStyle().Background(background).Render(" ") + cancel -// return modalBorderStyle(modalHeight, modalWidth).Render(title + "\n\n" + content + "\n\n" + tip) -// } diff --git a/src/internal/tui/component/component.go b/src/internal/tui/component/component.go index 041a94e1..287c977f 100644 --- a/src/internal/tui/component/component.go +++ b/src/internal/tui/component/component.go @@ -2,11 +2,11 @@ package component import ( "fmt" - "sort" + "slices" "strings" - "github.com/charmbracelet/bubbles/help" blist "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -20,11 +20,14 @@ import ( const ( height = 20 width = 12 - pickerHeight = 20 - pickerWidth = 80 dialogFixedWidth = 40 ) +const ( + componentPicker common.PickerKind = "component" + frameworkPicker common.PickerKind = "framework" +) + // NewComponentDefinitionModel create new model for component definition view func NewComponentDefinitionModel(oscalComponent *oscalTypes_1_1_2.ComponentDefinition) Model { var selectedComponent component @@ -35,76 +38,91 @@ func NewComponentDefinitionModel(oscalComponent *oscalTypes_1_1_2.ComponentDefin frameworks := make([]framework, 0) if oscalComponent != nil { - componentFrameworks := oscal.NewComponentFrameworks(oscalComponent) - validationStore := validationstore.NewValidationStore() if oscalComponent.BackMatter != nil { validationStore = validationstore.NewValidationStoreFromBackMatter(*oscalComponent.BackMatter) } - for uuid, c := range componentFrameworks { - frameworks := make([]framework, 0) - for k, f := range c.Frameworks { - controls := make([]control, 0) - - for _, controlImpl := range f { - for _, implementedRequirement := range controlImpl.ImplementedRequirements { - // get validations from implementedRequirement.Links - validationLinks := make([]validationLink, 0) - if implementedRequirement.Links != nil { - for _, link := range *implementedRequirement.Links { - if pkgcommon.IsLulaLink(link) { - validation, err := validationStore.GetLulaValidation(link.Href) - if err == nil { - // add the lula validation to the validations array - validationLinks = append(validationLinks, validationLink{ - text: link.Text, - validation: validation, - }) + if oscalComponent.Components != nil { + for cIdx, c := range *oscalComponent.Components { + // for each component, add the control implementation to the framework + componentFrameworks := make([]framework, 0) + if c.ControlImplementations != nil { + for ctrlImpIdx, controlImpl := range *c.ControlImplementations { + // get the controls for each framework + controls := make([]control, 0, len(controlImpl.ImplementedRequirements)) + for reqIdx, implementedRequirement := range controlImpl.ImplementedRequirements { + // get validations from implementedRequirement.Links + validationLinks := make([]validationLink, 0) + if implementedRequirement.Links != nil { + for _, link := range *implementedRequirement.Links { + if pkgcommon.IsLulaLink(link) { + validation, err := validationStore.GetLulaValidation(link.Href) + if err == nil { + // add the lula validation to the validations array + validationLinks = append(validationLinks, validationLink{ + oscalLink: &link, //&(*(*c.ControlImplementations)[ctrlImpIdx].ImplementedRequirements[reqIdx].Links)[linkIdx], + text: link.Text, + validation: validation, + }) + } } } } + + controls = append(controls, control{ + oscalControl: &(*c.ControlImplementations)[ctrlImpIdx].ImplementedRequirements[reqIdx], //&implementedRequirement, + title: implementedRequirement.ControlId, + uuid: implementedRequirement.UUID, + validations: validationLinks, + }) } - controls = append(controls, control{ - title: implementedRequirement.ControlId, - uuid: implementedRequirement.UUID, - desc: implementedRequirement.Description, - remarks: implementedRequirement.Remarks, - validations: validationLinks, + // sort controls by title + slices.SortStableFunc(controls, func(a, b control) int { + return oscal.CompareControlsInt(a.title, b.title) }) + + componentFrameworks = append(componentFrameworks, framework{ + oscalFramework: &(*c.ControlImplementations)[ctrlImpIdx], //&controlImpl, + name: controlImpl.Source, + uuid: controlImpl.UUID, + controls: controls, + }) + + // Add named framework if set + status, value := oscal.GetProp("framework", oscal.LULA_NAMESPACE, controlImpl.Props) + if status { + componentFrameworks = append(componentFrameworks, framework{ + oscalFramework: &(*c.ControlImplementations)[ctrlImpIdx], //&controlImpl, + name: value, + uuid: controlImpl.UUID, + controls: controls, + }) + } } } - // sort controls by title - sort.Slice(controls, func(i, j int) bool { - // custom sort function to sort controls by title - return oscal.CompareControls(controls[i].title, controls[j].title) - }) - frameworks = append(frameworks, framework{ - name: k, - controls: controls, + // sort componentFrameworks by name + slices.SortStableFunc(componentFrameworks, func(a, b framework) int { + return strings.Compare(a.name, b.name) }) + components = append(components, component{ + oscalComponent: &(*oscalComponent.Components)[cIdx], //&c, + uuid: c.UUID, + title: c.Title, + desc: c.Description, + frameworks: componentFrameworks, + }) } - // sort frameworks by name - sort.Slice(frameworks, func(i, j int) bool { - return frameworks[i].name < frameworks[j].name - }) - - components = append(components, component{ - uuid: uuid, - title: c.Component.Title, - desc: c.Component.Description, - frameworks: frameworks, - }) } } if len(components) > 0 { // sort components by title - sort.Slice(components, func(i, j int) bool { - return components[i].title < components[j].title + slices.SortStableFunc(components, func(a, b component) int { + return strings.Compare(a.title, b.title) }) selectedComponent = components[0] @@ -122,16 +140,24 @@ func NewComponentDefinitionModel(oscalComponent *oscalTypes_1_1_2.ComponentDefin } } - componentPicker := viewport.New(pickerWidth, pickerHeight) - componentPicker.Style = common.OverlayStyle + componentItems := make([]string, len(components)) + for i, c := range components { + componentItems[i] = getComponentText(c) + } + componentPicker := common.NewPickerModel("Select a Component", componentPicker, componentItems, 0) - frameworkPicker := viewport.New(pickerWidth, pickerHeight) - frameworkPicker.Style = common.OverlayStyle + frameworkItems := make([]string, len(frameworks)) + for i, f := range frameworks { + frameworkItems[i] = getFrameworkText(f) + } + frameworkPicker := common.NewPickerModel("Select a Framework", frameworkPicker, frameworkItems, 0) l := blist.New(viewedControls, common.NewUnfocusedDelegate(), width, height) + l.SetShowHelp(false) // help to be at top right l.KeyMap = common.FocusedListKeyMap() v := blist.New(viewedValidations, common.NewUnfocusedDelegate(), width, height) + v.SetShowHelp(false) // help to be at top right v.KeyMap = common.UnfocusedListKeyMap() controlPicker := viewport.New(width, height) @@ -139,14 +165,29 @@ func NewComponentDefinitionModel(oscalComponent *oscalTypes_1_1_2.ComponentDefin remarks := viewport.New(width, height) remarks.Style = common.PanelStyle + remarks.MouseWheelEnabled = false + remarksEditor := textarea.New() + remarksEditor.CharLimit = 0 + remarksEditor.KeyMap = common.UnfocusedTextAreaKeyMap() + description := viewport.New(width, height) description.Style = common.PanelStyle + description.MouseWheelEnabled = false + descriptionEditor := textarea.New() + descriptionEditor.CharLimit = 0 + descriptionEditor.KeyMap = common.UnfocusedTextAreaKeyMap() + validationPicker := viewport.New(width, height) validationPicker.Style = common.PanelStyle + help := common.NewHelpModel(false) + help.OneLine = true + help.ShortHelp = shortHelpNoFocus + return Model{ keys: componentKeys, - help: help.New(), + help: help, + componentModel: oscalComponent, components: components, selectedComponent: selectedComponent, componentPicker: componentPicker, @@ -156,7 +197,9 @@ func NewComponentDefinitionModel(oscalComponent *oscalTypes_1_1_2.ComponentDefin controlPicker: controlPicker, controls: l, remarks: remarks, + remarksEditor: remarksEditor, description: description, + descriptionEditor: descriptionEditor, validationPicker: validationPicker, validations: v, } @@ -170,6 +213,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd + // up front so it doesn't capture the first key ('e') + if m.remarksEditor.Focused() { + m.remarksEditor, cmd = m.remarksEditor.Update(msg) + cmds = append(cmds, cmd) + } else if m.descriptionEditor.Focused() { + m.descriptionEditor, cmd = m.descriptionEditor.Update(msg) + cmds = append(cmds, cmd) + } + switch msg := msg.(type) { case tea.WindowSizeMsg: m.UpdateSizing(msg.Height-common.TabOffset, msg.Width) @@ -178,109 +230,50 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.open { k := msg.String() switch k { - - case common.ContainsKey(k, m.keys.Quit.Keys()): - return m, tea.Quit - case common.ContainsKey(k, m.keys.Help.Keys()): m.help.ShowAll = !m.help.ShowAll case common.ContainsKey(k, m.keys.NavigateLeft.Keys()): - if m.focus == 0 { - m.focus = maxFocus - } else { - m.focus-- + if !m.componentPicker.Open && !m.frameworkPicker.Open { + if m.focus == 0 { + m.focus = maxFocus + } else { + m.focus-- + } + m.updateKeyBindings() } - m.updateKeyBindings() case common.ContainsKey(k, m.keys.NavigateRight.Keys()): - m.focus = (m.focus + 1) % (maxFocus + 1) - m.updateKeyBindings() - - case common.ContainsKey(k, m.keys.Up.Keys()): - if m.inComponentOverlay && m.selectedComponentIndex > 0 { - m.selectedComponentIndex-- - m.componentPicker.SetContent(m.updateComponentPickerContent()) - } else if m.inFrameworkOverlay && m.selectedFrameworkIndex > 0 { - m.selectedFrameworkIndex-- - m.frameworkPicker.SetContent(m.updateFrameworkPickerContent()) - } - - case common.ContainsKey(k, m.keys.Down.Keys()): - if m.inComponentOverlay && m.selectedComponentIndex < len(m.components)-1 { - m.selectedComponentIndex++ - m.componentPicker.SetContent(m.updateComponentPickerContent()) - } else if m.inFrameworkOverlay && m.selectedFrameworkIndex < len(m.selectedComponent.frameworks)-1 { - m.selectedFrameworkIndex++ - m.frameworkPicker.SetContent(m.updateFrameworkPickerContent()) + if !m.componentPicker.Open && !m.frameworkPicker.Open { + m.focus = (m.focus + 1) % (maxFocus + 1) + m.updateKeyBindings() } case common.ContainsKey(k, m.keys.Confirm.Keys()): switch m.focus { case focusComponentSelection: - if m.inComponentOverlay { - if len(m.components) > 1 { - m.selectedComponent = m.components[m.selectedComponentIndex] - m.selectedFrameworkIndex = 0 - - // Update controls list - if len(m.components[m.selectedComponentIndex].frameworks) > 0 { - m.selectedFramework = m.components[m.selectedComponentIndex].frameworks[m.selectedFrameworkIndex] - } else { - m.selectedFramework = framework{} + if len(m.components) > 0 && !m.componentPicker.Open { + return m, func() tea.Msg { + return common.PickerOpenMsg{ + Kind: componentPicker, } - controlItems := make([]blist.Item, len(m.selectedFramework.controls)) - if len(m.selectedFramework.controls) > 0 { - for i, c := range m.selectedFramework.controls { - controlItems[i] = c - } - } - m.controls.SetItems(controlItems) - m.controls.SetDelegate(common.NewUnfocusedDelegate()) - - // Update remarks, description, and validations - m.remarks.SetContent("") - m.description.SetContent("") - m.validations.SetItems(make([]blist.Item, 0)) } - - m.inComponentOverlay = false - } else { - m.inComponentOverlay = true - m.componentPicker.SetContent(m.updateComponentPickerContent()) } + case focusFrameworkSelection: - if m.inFrameworkOverlay { - if len(m.components) != 0 && len(m.components[m.selectedComponentIndex].frameworks) > 1 { - m.selectedFramework = m.components[m.selectedComponentIndex].frameworks[m.selectedFrameworkIndex] - - // Update controls list - controlItems := make([]blist.Item, len(m.selectedFramework.controls)) - if len(m.selectedFramework.controls) > 0 { - for i, c := range m.selectedFramework.controls { - controlItems[i] = c - } + if len(m.frameworks) > 0 && !m.frameworkPicker.Open { + return m, func() tea.Msg { + return common.PickerOpenMsg{ + Kind: frameworkPicker, } - m.controls.SetItems(controlItems) - m.controls.SetDelegate(common.NewUnfocusedDelegate()) - - // Update remarks, description, and validations - m.remarks.SetContent("") - m.description.SetContent("") - m.validations.SetItems(make([]blist.Item, 0)) } - - m.inFrameworkOverlay = false - } else { - m.inFrameworkOverlay = true - m.frameworkPicker.SetContent(m.updateFrameworkPickerContent()) } case focusControls: if selectedItem := m.controls.SelectedItem(); selectedItem != nil { m.selectedControl = m.controls.SelectedItem().(control) - m.remarks.SetContent(m.selectedControl.remarks) - m.description.SetContent(m.selectedControl.desc) + m.remarks.SetContent(m.selectedControl.oscalControl.Remarks) + m.description.SetContent(m.selectedControl.oscalControl.Description) // update validations list for selected control validationItems := make([]blist.Item, len(m.selectedControl.validations)) @@ -294,22 +287,111 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if selectedItem := m.validations.SelectedItem(); selectedItem != nil { m.selectedValidation = selectedItem.(validationLink) } + + case focusRemarks: + if m.remarksEditor.Focused() { + remarks := m.remarksEditor.Value() + m.UpdateRemarks(remarks) + m.remarksEditor.Blur() + m.remarks.SetContent(remarks) + m.updateKeyBindings() + } + + case focusDescription: + if m.descriptionEditor.Focused() { + description := m.descriptionEditor.Value() + m.UpdateDescription(description) + m.descriptionEditor.Blur() + m.description.SetContent(description) + m.updateKeyBindings() + } + } + + case common.ContainsKey(k, m.keys.Edit.Keys()): + if m.selectedControl.oscalControl != nil { + switch m.focus { + case focusRemarks: + if !m.remarksEditor.Focused() { + m.remarksEditor.SetValue(m.selectedControl.oscalControl.Remarks) + m.remarks.SetContent(m.remarksEditor.View()) + _ = m.remarksEditor.Focus() + m.updateKeyBindings() + } + case focusDescription: + if !m.descriptionEditor.Focused() { + m.descriptionEditor.SetValue(m.selectedControl.oscalControl.Description) + m.description.SetContent(m.descriptionEditor.View()) + _ = m.descriptionEditor.Focus() + m.updateKeyBindings() + } + } } case common.ContainsKey(k, m.keys.Cancel.Keys()): - if m.inComponentOverlay { - m.inComponentOverlay = false - } else if m.inFrameworkOverlay { - m.inFrameworkOverlay = false + if m.selectedControl.oscalControl != nil { + switch m.focus { + case focusRemarks: + if m.remarksEditor.Focused() { + m.remarksEditor.Blur() + m.remarks.SetContent(m.selectedControl.oscalControl.Remarks) + m.updateKeyBindings() + } + + case focusDescription: + if m.descriptionEditor.Focused() { + m.descriptionEditor.Blur() + m.description.SetContent(m.selectedControl.oscalControl.Description) + m.updateKeyBindings() + } + } } } } + + case common.PickerItemSelected: + // reset all the controls, contents - if component is selected, reset the framework list as well + if msg.From == componentPicker { + m.selectedComponent = m.components[msg.Selected] + m.selectedFramework = framework{} + + // Update controls list + if len(m.components[msg.Selected].frameworks) > 0 { + m.selectedFramework = m.components[msg.Selected].frameworks[0] + } + } else if msg.From == frameworkPicker { + m.selectedFramework = m.selectedComponent.frameworks[msg.Selected] + } + if m.selectedFramework.oscalFramework != nil { + controlItems := make([]blist.Item, len(m.selectedFramework.controls)) + if len(m.selectedFramework.controls) > 0 { + for i, c := range m.selectedFramework.controls { + controlItems[i] = c + } + } + m.controls.SetItems(controlItems) + m.controls.ResetSelected() + } + // Update remarks, description, and validations + m.controls.SetDelegate(common.NewUnfocusedDelegate()) + m.controls.ResetSelected() + m.controls.ResetFilter() + m.selectedControl = control{} + m.remarks.SetContent("") + m.remarksEditor.SetValue("") + m.description.SetContent("") + m.descriptionEditor.SetValue("") + m.validations.SetItems(make([]blist.Item, 0)) + m.validations.ResetSelected() + m.validations.ResetFilter() + m.selectedValidation = validationLink{} } - m.componentPicker, cmd = m.componentPicker.Update(msg) + mdl, cmd := m.componentPicker.Update(msg) + m.componentPicker = mdl.(common.PickerModel) cmds = append(cmds, cmd) - m.frameworkPicker, cmd = m.frameworkPicker.Update(msg) + mdl, cmd = m.frameworkPicker.Update(msg) + m.frameworkPicker = mdl.(common.PickerModel) cmds = append(cmds, cmd) m.remarks, cmd = m.remarks.Update(msg) @@ -328,21 +410,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) View() string { - if m.inComponentOverlay { + if m.componentPicker.Open { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.componentPicker.View(), lipgloss.WithWhitespaceChars(" ")) } - if m.inFrameworkOverlay { + if m.frameworkPicker.Open { return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.frameworkPicker.View(), lipgloss.WithWhitespaceChars(" ")) } return m.mainView() } func (m Model) mainView() string { - // Add help panel at the top left - helpStyle := common.HelpStyle(m.width) - helpView := helpStyle.Render(m.help.View(m.keys)) - - // Add viewport styles + // Add viewport and focus styles focusedViewport := common.PanelStyle.BorderForeground(common.Focused) focusedViewportHeaderColor := common.Focused focusedDialogBox := common.DialogBoxStyle.BorderForeground(common.Focused) @@ -376,6 +454,9 @@ func (m Model) mainView() string { validationPickerViewport = focusedViewport validationHeaderColor = focusedViewportHeaderColor } + // Add help panel at the top right + helpStyle := common.HelpStyle(m.width) + helpView := helpStyle.Render(m.help.View()) // Add widgets for dialogs selectedComponentLabel := common.LabelStyle.Render("Selected Component") @@ -403,6 +484,13 @@ func (m Model) mainView() string { m.validationPicker.Style = validationPickerViewport m.validationPicker.SetContent(m.validations.View()) + // remarksView = m.remarks.View() + if m.remarksEditor.Focused() { + m.remarks.SetContent(lipgloss.JoinVertical(lipgloss.Top, m.remarksEditor.View())) + } else if m.descriptionEditor.Focused() { + m.description.SetContent(lipgloss.JoinVertical(lipgloss.Top, m.descriptionEditor.View())) + } + remarksPanel := fmt.Sprintf("%s\n%s", common.HeaderView("Remarks", m.remarks.Width-common.PanelStyle.GetPaddingRight(), remarksHeaderColor), m.remarks.View()) descriptionPanel := fmt.Sprintf("%s\n%s", common.HeaderView("Description", m.description.Width-common.PanelStyle.GetPaddingRight(), descHeaderColor), m.description.View()) validationsPanel := fmt.Sprintf("%s\n%s", common.HeaderView("Validations", m.validationPicker.Width-common.PanelStyle.GetPaddingRight(), validationHeaderColor), m.validationPicker.View()) @@ -415,51 +503,14 @@ func (m Model) mainView() string { func getComponentText(component component) string { if component.uuid == "" { - return "No Component Selected" + return "No Components" } return fmt.Sprintf("%s - %s", component.title, component.uuid) } func getFrameworkText(framework framework) string { - return framework.name -} - -func (m Model) updateComponentPickerContent() string { - helpStyle := common.HelpStyle(pickerWidth) - helpView := helpStyle.Render(help.New().View(common.PickerHotkeys)) - - s := strings.Builder{} - s.WriteString("Select a Component:\n\n") - - for i, component := range m.components { - if m.selectedComponentIndex == i { - s.WriteString("(•) ") //[✔] Todo: many components? - } else { - s.WriteString("( ) ") - } - s.WriteString(getComponentText(component)) - s.WriteString("\n") + if framework.name == "" { + return "No Frameworks" } - - return lipgloss.JoinVertical(lipgloss.Top, helpView, s.String()) -} - -func (m Model) updateFrameworkPickerContent() string { - helpStyle := common.HelpStyle(pickerWidth) - helpView := helpStyle.Render(help.New().View(common.PickerHotkeys)) - - s := strings.Builder{} - s.WriteString("Select a Framework:\n\n") - - for i, fw := range m.selectedComponent.frameworks { - if m.selectedFrameworkIndex == i { - s.WriteString("(•) ") - } else { - s.WriteString("( ) ") - } - s.WriteString(getFrameworkText(fw)) - s.WriteString("\n") - } - - return lipgloss.JoinVertical(lipgloss.Top, helpView, s.String()) + return framework.name } diff --git a/src/internal/tui/component/component_test.go b/src/internal/tui/component/component_test.go new file mode 100644 index 00000000..cb6fa8a4 --- /dev/null +++ b/src/internal/tui/component/component_test.go @@ -0,0 +1,63 @@ +package component_test + +import ( + "os" + "testing" + + oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" + "github.com/defenseunicorns/lula/src/internal/testhelpers" + "github.com/defenseunicorns/lula/src/internal/tui/component" + "github.com/defenseunicorns/lula/src/pkg/common/oscal" +) + +// TestEditComponentDefinitionModel tests that a component definition model can be modified, written, and re-read +func TestEditComponentDefinitionModel(t *testing.T) { + tempOscalFile := testhelpers.CreateTempFile(t, "yaml") + defer os.Remove(tempOscalFile.Name()) + + oscalModel := testhelpers.OscalFromPath(t, "../../../test/unit/common/oscal/valid-generated-component.yaml") + model := component.NewComponentDefinitionModel(oscalModel.ComponentDefinition) + + testControlId := "ac-1" + testRemarks := "test remarks" + testDescription := "test description" + + model.TestSetSelectedControl(testControlId) + model.UpdateRemarks(testRemarks) + model.UpdateDescription(testDescription) + + // Create OSCAL model + mdl := &oscalTypes_1_1_2.OscalCompleteSchema{ + ComponentDefinition: model.GetComponentDefinition(), + } + + // Write the model to a temp file + err := oscal.OverwriteOscalModel(tempOscalFile.Name(), mdl) + if err != nil { + t.Errorf("error overwriting oscal model: %v", err) + } + + // Read the model from the temp file + modifiedOscalModel := testhelpers.OscalFromPath(t, tempOscalFile.Name()) + compDefn := modifiedOscalModel.ComponentDefinition + if compDefn == nil { + t.Errorf("component definition is nil") + } + for _, c := range *compDefn.Components { + if c.ControlImplementations == nil { + t.Errorf("control implementations are nil") + } + for _, f := range *c.ControlImplementations { + for _, r := range f.ImplementedRequirements { + if r.ControlId == testControlId { + if r.Remarks != testRemarks { + t.Errorf("Expected remarks to be %s, got %s", testRemarks, r.Remarks) + } + if r.Description != testDescription { + t.Errorf("Expected remarks to be %s, got %s", testDescription, r.Description) + } + } + } + } + } +} diff --git a/src/internal/tui/component/keys.go b/src/internal/tui/component/keys.go index 09b7b79f..bb652324 100644 --- a/src/internal/tui/component/keys.go +++ b/src/internal/tui/component/keys.go @@ -9,76 +9,68 @@ type keys struct { Edit key.Binding Generate key.Binding Confirm key.Binding + Select key.Binding + Save key.Binding Cancel key.Binding Navigation key.Binding NavigateLeft key.Binding NavigateRight key.Binding SwitchModels key.Binding - Up key.Binding - Down key.Binding Help key.Binding Quit key.Binding } var componentKeys = keys{ - Quit: common.CommonHotkeys.Quit, - Help: common.CommonHotkeys.Help, - Edit: key.NewBinding( - key.WithKeys("e"), - key.WithHelp("e", "edit"), - ), - Confirm: common.PickerHotkeys.Confirm, - Cancel: common.PickerHotkeys.Cancel, - Navigation: key.NewBinding( - key.WithKeys("left", "h", "right", "l"), - key.WithHelp("←/h, →/l", "navigation"), - ), - NavigateLeft: key.NewBinding( - key.WithKeys("left", "h"), - key.WithHelp("←/h", "navigate left"), - ), - NavigateRight: key.NewBinding( - key.WithKeys("right", "l"), - key.WithHelp("→/l", "navigate right"), - ), - SwitchModels: key.NewBinding( - key.WithKeys("tab", "shift+tab"), - key.WithHelp("tab/shift+tab", "switch models"), - ), - Up: common.PickerHotkeys.Up, - Down: common.PickerHotkeys.Down, + Quit: common.CommonKeys.Quit, + Help: common.CommonKeys.Help, + Edit: common.CommonKeys.Edit, + Save: common.CommonKeys.Save, + Select: common.CommonKeys.Select, + Confirm: common.CommonKeys.Confirm, + Cancel: common.CommonKeys.Cancel, + Navigation: common.CommonKeys.Navigation, + NavigateLeft: common.CommonKeys.NavigateLeft, + NavigateRight: common.CommonKeys.NavigateRight, + SwitchModels: common.CommonKeys.NavigateModels, } -func (k keys) ShortHelp() []key.Binding { - return []key.Binding{k.Navigation, k.Help} +var componentEditKeys = keys{ + Confirm: common.PickerKeys.Select, + Cancel: common.PickerKeys.Cancel, } -func (k keys) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.Confirm}, {k.Navigation}, {k.SwitchModels}, {k.Help}, {k.Quit}, +// Focus key help +var ( + // No focus + shortHelpNoFocus = []key.Binding{ + componentKeys.Navigation, componentKeys.SwitchModels, componentKeys.Help, + } + fullHelpNoFocusOneLine = []key.Binding{ + componentKeys.Navigation, componentKeys.SwitchModels, componentKeys.Help, + } + fullHelpNoFocus = [][]key.Binding{ + {componentKeys.Navigation}, {componentKeys.SwitchModels}, {componentKeys.Help}, } -} - -func (m *Model) updateKeyBindings() { - m.controls.KeyMap = common.UnfocusedListKeyMap() - // m.controls.SetDelegate(common.NewUnfocusedDelegate()) - m.validations.KeyMap = common.UnfocusedListKeyMap() - m.validations.SetDelegate(common.NewUnfocusedDelegate()) - m.remarks.KeyMap = common.UnfocusedPanelKeyMap() - m.description.KeyMap = common.UnfocusedPanelKeyMap() + // focus dialog box + shortHelpDialogBox = []key.Binding{ + componentKeys.Select, componentKeys.Navigation, componentKeys.SwitchModels, componentKeys.Help, + } + fullHelpDialogBoxOneLine = []key.Binding{ + componentKeys.Select, componentKeys.Save, componentKeys.Navigation, componentKeys.SwitchModels, componentKeys.Help, + } + fullHelpDialogBox = [][]key.Binding{ + {componentKeys.Select}, {componentKeys.Save}, {componentKeys.Navigation}, {componentKeys.SwitchModels}, {componentKeys.Help}, + } - switch m.focus { - case focusComponentSelection: - case focusValidations: - m.validations.KeyMap = common.FocusedListKeyMap() - m.validations.SetDelegate(common.NewFocusedDelegate()) - case focusControls: - m.controls.KeyMap = common.FocusedListKeyMap() - m.controls.SetDelegate(common.NewFocusedDelegate()) - case focusRemarks: - m.remarks.KeyMap = common.FocusedPanelKeyMap() - case focusDescription: - m.description.KeyMap = common.FocusedPanelKeyMap() + // focus editable dialog box + shortHelpEditableDialogBox = []key.Binding{ + componentKeys.Edit, componentKeys.Save, componentKeys.Navigation, componentKeys.SwitchModels, componentKeys.Help, } -} + fullHelpEditableDialogBoxOneLine = []key.Binding{ + componentKeys.Edit, componentKeys.Save, componentKeys.Navigation, componentKeys.SwitchModels, componentKeys.Help, + } + fullHelpEditableDialogBox = [][]key.Binding{ + {componentKeys.Edit}, {componentKeys.Save}, {componentKeys.Navigation}, {componentKeys.SwitchModels}, {componentKeys.Help}, + } +) diff --git a/src/internal/tui/component/types.go b/src/internal/tui/component/types.go index 2baeebf4..04174d64 100644 --- a/src/internal/tui/component/types.go +++ b/src/internal/tui/component/types.go @@ -1,39 +1,39 @@ package component import ( - "github.com/charmbracelet/bubbles/help" blist "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" "github.com/defenseunicorns/lula/src/internal/tui/common" "github.com/defenseunicorns/lula/src/types" ) type Model struct { - open bool - help help.Model - keys keys - focus focus - inComponentOverlay bool - components []component - selectedComponent component - selectedComponentIndex int - componentPicker viewport.Model - inFrameworkOverlay bool - frameworks []framework - selectedFramework framework - selectedFrameworkIndex int - frameworkPicker viewport.Model - controlPicker viewport.Model - controls blist.Model - selectedControl control - remarks viewport.Model - description viewport.Model - validationPicker viewport.Model - validations blist.Model - selectedValidation validationLink - width int - height int + open bool + help common.HelpModel + keys keys + focus focus + componentModel *oscalTypes_1_1_2.ComponentDefinition + components []component + selectedComponent component + componentPicker common.PickerModel + frameworks []framework + selectedFramework framework + frameworkPicker common.PickerModel + controlPicker viewport.Model + controls blist.Model + selectedControl control + remarks viewport.Model + remarksEditor textarea.Model + description viewport.Model + descriptionEditor textarea.Model + validationPicker viewport.Model + validations blist.Model + selectedValidation validationLink + width int + height int } type focus int @@ -51,16 +51,29 @@ const ( var maxFocus = focusValidations type component struct { + oscalComponent *oscalTypes_1_1_2.DefinedComponent uuid, title, desc string frameworks []framework } type framework struct { - name string - controls []control + oscalFramework *oscalTypes_1_1_2.ControlImplementationSet + name, uuid string + controls []control } +type control struct { + oscalControl *oscalTypes_1_1_2.ImplementedRequirementControlImplementation + uuid, title string + validations []validationLink +} + +func (i control) Title() string { return i.title } +func (i control) Description() string { return i.uuid } +func (i control) FilterValue() string { return i.title } + type validationLink struct { + oscalLink *oscalTypes_1_1_2.Link text string validation *types.LulaValidation } @@ -69,15 +82,6 @@ func (i validationLink) Title() string { return i.validation.Name } func (i validationLink) Description() string { return i.text } func (i validationLink) FilterValue() string { return i.validation.Name } -type control struct { - uuid, remarks, title, desc string - validations []validationLink -} - -func (i control) Title() string { return i.title } -func (i control) Description() string { return i.uuid } -func (i control) FilterValue() string { return i.title } - func (m *Model) Close() { m.open = false } @@ -87,6 +91,40 @@ func (m *Model) Open(height, width int) { m.UpdateSizing(height, width) } +// GetComponentDefinition returns the component definition model, used on save events +func (m *Model) GetComponentDefinition() *oscalTypes_1_1_2.ComponentDefinition { + return m.componentModel +} + +// TestSetSelectedControl is a test helper function to set the selected control +func (m *Model) TestSetSelectedControl(title string) { + var idx int + if len(m.selectedFramework.controls) > 0 { + for i, c := range m.selectedFramework.controls { + if c.title == title { + m.selectedControl = c + idx = i + break + } + } + } + m.controls.Select(idx) +} + +func (m *Model) UpdateRemarks(remarks string) { + // TODO: handle any race conditions updating the control? + if m.selectedControl.oscalControl != nil { + m.selectedControl.oscalControl.Remarks = remarks + } +} + +func (m *Model) UpdateDescription(description string) { + // TODO: handle any race conditions updating the control? + if m.selectedControl.oscalControl != nil { + m.selectedControl.oscalControl.Description = description + } +} + func (m *Model) UpdateSizing(height, width int) { m.height = height m.width = width @@ -98,28 +136,37 @@ func (m *Model) UpdateSizing(height, width int) { topSectionHeight := common.HelpStyle(m.width).GetHeight() + common.DialogBoxStyle.GetHeight() bottomSectionHeight := totalHeight - topSectionHeight + panelHeight := common.PanelTitleStyle.GetHeight() + common.PanelStyle.GetVerticalPadding() + common.PanelStyle.GetVerticalMargins() + 1 // 1 for border remarksOutsideHeight := bottomSectionHeight / 4 - remarksInsideHeight := remarksOutsideHeight - common.PanelTitleStyle.GetHeight() + remarksInsideHeight := remarksOutsideHeight - panelHeight descriptionOutsideHeight := bottomSectionHeight / 4 - descriptionInsideHeight := descriptionOutsideHeight - common.PanelTitleStyle.GetHeight() + descriptionInsideHeight := descriptionOutsideHeight - panelHeight validationsHeight := bottomSectionHeight - remarksOutsideHeight - descriptionOutsideHeight - 2*common.PanelTitleStyle.GetHeight() // Update widget sizing + m.help.Width = m.width + m.controls.SetHeight(m.height - common.PanelTitleStyle.GetHeight() - 1) m.controls.SetWidth(leftWidth - common.PanelStyle.GetHorizontalPadding()) m.controlPicker.Height = bottomSectionHeight m.controlPicker.Width = leftWidth - common.PanelStyle.GetHorizontalPadding() - m.remarks.Height = remarksInsideHeight - 1 + m.remarks.Height = remarksInsideHeight m.remarks.Width = rightWidth - m.remarks, _ = m.remarks.Update(tea.WindowSizeMsg{Width: rightWidth, Height: remarksInsideHeight - 1}) + m.remarks, _ = m.remarks.Update(tea.WindowSizeMsg{Width: rightWidth, Height: remarksInsideHeight}) // rebuild remarks for line wrapping? + + m.remarksEditor.SetHeight(m.remarks.Height - 1) + m.remarksEditor.SetWidth(m.remarks.Width - 5) // probably need to fix this to be a func - m.description.Height = descriptionInsideHeight - 1 + m.description.Height = descriptionInsideHeight m.description.Width = rightWidth - m.description, _ = m.description.Update(tea.WindowSizeMsg{Width: rightWidth, Height: descriptionInsideHeight - 1}) + m.description, _ = m.description.Update(tea.WindowSizeMsg{Width: rightWidth, Height: descriptionInsideHeight}) + + m.descriptionEditor.SetHeight(m.description.Height - 1) + m.descriptionEditor.SetWidth(m.description.Width - 5) // probably need to fix this to be a func m.validations.SetHeight(validationsHeight - common.PanelTitleStyle.GetHeight()) m.validations.SetWidth(rightWidth - common.PanelStyle.GetHorizontalPadding()) @@ -131,3 +178,185 @@ func (m *Model) UpdateSizing(height, width int) { func (m *Model) GetDimensions() (height, width int) { return m.height, m.width } + +func (m *Model) updateKeyBindings() { + m.outOfFocus() + m.updateFocusHelpKeys() + + switch m.focus { + + case focusControls: + m.controls.KeyMap = common.FocusedListKeyMap() + m.controls.SetDelegate(common.NewFocusedDelegate()) + + case focusValidations: + m.validations.KeyMap = common.FocusedListKeyMap() + m.validations.SetDelegate(common.NewFocusedDelegate()) + + case focusRemarks: + m.remarks.KeyMap = common.FocusedPanelKeyMap() + m.remarks.MouseWheelEnabled = true + if m.remarksEditor.Focused() { + m.remarksEditor.KeyMap = common.FocusedTextAreaKeyMap() + m.keys = componentEditKeys + } else { + m.remarksEditor.KeyMap = common.UnfocusedTextAreaKeyMap() + m.keys = componentKeys + } + + case focusDescription: + m.description.KeyMap = common.FocusedPanelKeyMap() + m.description.MouseWheelEnabled = true + if m.descriptionEditor.Focused() { + m.descriptionEditor.KeyMap = common.FocusedTextAreaKeyMap() + m.keys = componentEditKeys + } else { + m.descriptionEditor.KeyMap = common.UnfocusedTextAreaKeyMap() + m.keys = componentKeys + } + + } +} + +// func for outOfFocus to run just when focus switches between items +func (m *Model) outOfFocus() { + focusMinusOne := m.focus - 1 + focusPlusOne := m.focus + 1 + + if m.focus == 0 { + focusMinusOne = maxFocus + } + if m.focus == maxFocus { + focusPlusOne = 0 + } + + for _, f := range []focus{focusMinusOne, focusPlusOne} { + // Turn off keys for out of focus items + switch f { + case focusControls: + m.controls.KeyMap = common.UnfocusedListKeyMap() + + case focusValidations: + m.validations.KeyMap = common.UnfocusedListKeyMap() + m.validations.SetDelegate(common.NewUnfocusedDelegate()) + + case focusRemarks: + m.remarks.KeyMap = common.UnfocusedPanelKeyMap() + m.remarks.MouseWheelEnabled = false + + case focusDescription: + m.description.KeyMap = common.UnfocusedPanelKeyMap() + m.description.MouseWheelEnabled = false + } + } +} + +func (m *Model) updateFocusHelpKeys() { + switch m.focus { + case focusComponentSelection: + m.help.ShortHelp = shortHelpDialogBox + m.help.FullHelpOneLine = fullHelpDialogBoxOneLine + m.help.FullHelp = fullHelpDialogBox + case focusFrameworkSelection: + m.help.ShortHelp = shortHelpDialogBox + m.help.FullHelpOneLine = fullHelpDialogBoxOneLine + m.help.FullHelp = fullHelpDialogBox + case focusControls: + m.help.ShortHelp = common.ShortHelpList + m.help.FullHelpOneLine = common.FullHelpListOneLine + m.help.FullHelp = common.FullHelpList + case focusRemarks: + if m.remarksEditor.Focused() { + m.help.ShortHelp = common.ShortHelpEditing + m.help.FullHelpOneLine = common.FullHelpEditingOneLine + m.help.FullHelp = common.FullHelpEditing + } else { + m.help.ShortHelp = shortHelpEditableDialogBox + m.help.FullHelpOneLine = fullHelpEditableDialogBoxOneLine + m.help.FullHelp = fullHelpEditableDialogBox + } + case focusDescription: + if m.descriptionEditor.Focused() { + m.help.ShortHelp = common.ShortHelpEditing + m.help.FullHelpOneLine = common.FullHelpEditingOneLine + m.help.FullHelp = common.FullHelpEditing + } else { + m.help.ShortHelp = shortHelpEditableDialogBox + m.help.FullHelpOneLine = fullHelpEditableDialogBoxOneLine + m.help.FullHelp = fullHelpEditableDialogBox + } + case focusValidations: + m.help.ShortHelp = common.ShortHelpList + m.help.FullHelpOneLine = common.FullHelpListOneLine + m.help.FullHelp = common.FullHelpList + default: + m.help.ShortHelp = shortHelpNoFocus + m.help.FullHelpOneLine = fullHelpNoFocusOneLine + m.help.FullHelp = fullHelpNoFocus + } +} + +// func (m *Model) setNoFocusHelpKeys() { +// m.help.ShortHelp = []key.Binding{ +// componentKeys.Navigation, componentKeys.SwitchModels, componentKeys.Help, +// } +// m.help.FullHelpOneLine = []key.Binding{ +// componentKeys.Save, componentKeys.Navigation, componentKeys.SwitchModels, componentKeys.Help, componentKeys.Quit, +// } +// // This is currently unused - TODO: help overlay? +// m.help.FullHelp = [][]key.Binding{ +// {componentKeys.SwitchModels}, {componentKeys.Navigation}, {componentKeys.Help}, {componentKeys.Quit}, +// } +// } + +// func (m *Model) setDialogBoxHelpKeys() { +// m.help.ShortHelp = []key.Binding{ +// componentKeys.Select, componentKeys.Navigation, componentKeys.SwitchModels, componentKeys.Help, +// } +// m.help.FullHelpOneLine = []key.Binding{ +// componentKeys.Select, componentKeys.Save, componentKeys.Navigation, componentKeys.SwitchModels, componentKeys.Help, componentKeys.Quit, +// } +// // This is currently unused - TODO: help overlay? +// m.help.FullHelp = [][]key.Binding{ +// {componentKeys.SwitchModels}, {componentKeys.Navigation}, {componentKeys.Help}, {componentKeys.Quit}, +// } +// } + +// func (m *Model) setEditableDialogBoxHelpKeys() { +// m.help.ShortHelp = []key.Binding{ +// componentKeys.Edit, componentKeys.Save, componentKeys.Navigation, componentKeys.SwitchModels, componentKeys.Help, +// } +// m.help.FullHelpOneLine = []key.Binding{ +// componentKeys.Edit, componentKeys.Save, componentKeys.Navigation, componentKeys.SwitchModels, componentKeys.Help, componentKeys.Quit, +// } +// // This is currently unused - TODO: help overlay? +// m.help.FullHelp = [][]key.Binding{ +// {componentKeys.Edit}, {componentKeys.Navigation}, {componentKeys.Help}, {componentKeys.Quit}, +// } +// } + +// func (m *Model) setEditingDialogBoxHelpKeys() { +// m.help.ShortHelp = []key.Binding{ +// componentKeys.Confirm, componentKeys.Newline, componentKeys.Cancel, +// } +// m.help.FullHelpOneLine = []key.Binding{ +// componentKeys.Confirm, componentKeys.Newline, componentKeys.Cancel, componentKeys.Save, componentKeys.Help, componentKeys.Quit, +// } +// // This is currently unused - TODO: help overlay? +// m.help.FullHelp = [][]key.Binding{ +// {componentKeys.Confirm}, {componentKeys.Newline}, {componentKeys.Cancel}, {componentKeys.Quit}, +// } +// } + +// func (m *Model) setListHelpKeys() { +// m.help.ShortHelp = []key.Binding{ +// componentKeys.Select, componentKeys.Up, componentKeys.Down, common.CommonKeys.Filter, componentKeys.Help, +// } +// m.help.FullHelpOneLine = []key.Binding{ +// componentKeys.Select, componentKeys.Up, componentKeys.Down, common.CommonKeys.Filter, componentKeys.Cancel, componentKeys.Help, componentKeys.Quit, +// } +// // This is currently unused - TODO: help overlay? +// m.help.FullHelp = [][]key.Binding{ +// {componentKeys.Edit}, {componentKeys.Navigation}, {componentKeys.Help}, {componentKeys.Quit}, +// } +// } diff --git a/src/internal/tui/model.go b/src/internal/tui/model.go index 527dc0e3..d412fdff 100644 --- a/src/internal/tui/model.go +++ b/src/internal/tui/model.go @@ -1,22 +1,30 @@ package tui import ( + "encoding/json" "fmt" + "os" + "reflect" "strings" + "time" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" ar "github.com/defenseunicorns/lula/src/internal/tui/assessment_results" "github.com/defenseunicorns/lula/src/internal/tui/common" "github.com/defenseunicorns/lula/src/internal/tui/component" + "github.com/defenseunicorns/lula/src/pkg/common/oscal" ) type model struct { keys common.Keys tabs []string activeTab int + oscalFilePath string oscalModel *oscalTypes_1_1_2.OscalCompleteSchema + writtenOscalModel *oscalTypes_1_1_2.OscalCompleteSchema componentModel component.Model assessmentResultsModel ar.Model catalogModel common.TbdModal @@ -24,12 +32,13 @@ type model struct { assessmentPlanModel common.TbdModal systemSecurityPlanModel common.TbdModal profileModel common.TbdModal - warnModel common.WarnModal + closeModel common.PopupModel + saveModel common.SaveModel width int height int } -func NewOSCALModel(oscalModel *oscalTypes_1_1_2.OscalCompleteSchema) model { +func NewOSCALModel(oscalModel *oscalTypes_1_1_2.OscalCompleteSchema, oscalFilePath string, dumpFile *os.File) model { if oscalModel == nil { oscalModel = new(oscalTypes_1_1_2.OscalCompleteSchema) } @@ -44,10 +53,31 @@ func NewOSCALModel(oscalModel *oscalTypes_1_1_2.OscalCompleteSchema) model { "Profile", } + if dumpFile != nil { + common.DumpFile = dumpFile + } + + if oscalFilePath == "" { + oscalFilePath = "oscal.yaml" + } + + writtenOscalModel := new(oscalTypes_1_1_2.OscalCompleteSchema) + err := DeepCopy(oscalModel, writtenOscalModel) + if err != nil { + common.PrintToLog("error creating deep copy of oscal model: %v", err) + } + + closeModel := common.NewPopupModel("Quit Console", "Are you sure you want to quit the Lula Console?", []key.Binding{common.CommonKeys.Confirm, common.CommonKeys.Cancel}) + saveModel := common.NewSaveModel(oscalFilePath) + return model{ - keys: common.CommonHotkeys, + keys: common.CommonKeys, tabs: tabs, + oscalFilePath: oscalFilePath, oscalModel: oscalModel, + writtenOscalModel: writtenOscalModel, + closeModel: closeModel, + saveModel: saveModel, componentModel: component.NewComponentDefinitionModel(oscalModel.ComponentDefinition), assessmentResultsModel: ar.NewAssessmentResultsModel(oscalModel.AssessmentResults), systemSecurityPlanModel: common.NewTbdModal("System Security Plan"), @@ -60,6 +90,42 @@ func NewOSCALModel(oscalModel *oscalTypes_1_1_2.OscalCompleteSchema) model { } } +// UpdateOscalModel runs on edit + confirm cmds(?) +func (m *model) UpdateOscalModel() { + m.oscalModel = &oscalTypes_1_1_2.OscalCompleteSchema{ + ComponentDefinition: m.componentModel.GetComponentDefinition(), + } +} + +func (m *model) isModelSaved() bool { + m.oscalModel = &oscalTypes_1_1_2.OscalCompleteSchema{ + ComponentDefinition: m.componentModel.GetComponentDefinition(), + } + + return reflect.DeepEqual(m.writtenOscalModel, m.oscalModel) +} + +// WriteOscalModel runs on save cmds +func (m *model) writeOscalModel() tea.Msg { + common.PrintToLog("oscalFilePath: %s", m.oscalFilePath) + + saveStart := time.Now() + err := oscal.OverwriteOscalModel(m.oscalFilePath, m.oscalModel) + saveDuration := time.Since(saveStart) + // just adding a minimum of 2 seconds to the "saving" popup + if saveDuration < time.Second*2 { + time.Sleep(time.Second*2 - saveDuration) + } + if err != nil { + common.PrintToLog("error writing oscal model: %v", err) + return common.SaveFailMsg{Err: err} + } + common.PrintToLog("model saved") + + DeepCopy(m.oscalModel, m.writtenOscalModel) + return common.SaveSuccessMsg{} +} + func (m model) Init() tea.Cmd { return nil } @@ -68,6 +134,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd + common.DumpToLog(msg) + switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width @@ -77,9 +145,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { k := msg.String() switch k { - case common.ContainsKey(k, m.keys.Quit.Keys()): - return m, tea.Quit - case common.ContainsKey(k, m.keys.ModelRight.Keys()): m.activeTab = (m.activeTab + 1) % len(m.tabs) @@ -90,9 +155,78 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.activeTab = m.activeTab - 1 } + case common.ContainsKey(k, m.keys.Confirm.Keys()): + if m.closeModel.Open { + return m, tea.Quit + } + + case common.ContainsKey(k, m.keys.Save.Keys()): + m.saveModel.RenderedDuringQuit = false + if m.closeModel.Open { + m.saveModel.RenderedDuringQuit = true + if m.isModelSaved() { + return m, nil + } else { + m.closeModel.Open = false + } + } + + m.saveModel.Open = true + m.saveModel.Save = true + if m.isModelSaved() { + m.saveModel.Save = false + m.saveModel.Content = "No changes to save" + return m, nil + } + m.saveModel.Content = fmt.Sprintf("Save changes to %s?", m.saveModel.FilePath) + // warning if file exists + if _, err := os.Stat(m.oscalFilePath); err == nil { + m.saveModel.Warning = fmt.Sprintf("%s will be overwritten", m.oscalFilePath) + } + + return m, nil + + case common.ContainsKey(k, m.keys.Cancel.Keys()): + if m.closeModel.Open { + m.closeModel.Open = false + } else if m.saveModel.Open { + m.saveModel.Open = false + } + + case common.ContainsKey(k, m.keys.Quit.Keys()): + // add quit warn pop-up + if !m.isModelSaved() { + m.closeModel.Warning = "Changes not written" + m.closeModel.Help.ShortHelp = []key.Binding{common.CommonKeys.Confirm, common.CommonKeys.Save, common.CommonKeys.Cancel} + } + if m.closeModel.Open { + return m, tea.Quit + } else { + m.closeModel.Open = true + } + } + + case common.SaveModelMsg: + saveResultMsg := m.writeOscalModel() + common.DumpToLog(saveResultMsg) + + cmds = append(cmds, func() tea.Msg { + return saveResultMsg + }, func() tea.Msg { + time.Sleep(time.Second * 2) + return common.SaveCloseAndResetMsg{} + }) + + if (saveResultMsg == common.SaveSuccessMsg{}) && msg.InQuitWorkflow { + cmds = append(cmds, tea.Quit) } + return m, tea.Sequence(cmds...) } + mdl, cmd := m.saveModel.Update(msg) + m.saveModel = mdl.(common.SaveModel) + cmds = append(cmds, cmd) + tabModel, cmd := m.loadTabModel(msg) if tabModel != nil { switch m.tabs[m.activeTab] { @@ -104,12 +238,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) } - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) } func (m model) View() string { + if m.closeModel.Open { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.closeModel.View(), lipgloss.WithWhitespaceChars(" ")) + } + if m.saveModel.Open { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.saveModel.View(), lipgloss.WithWhitespaceChars(" ")) + } + return m.mainView() +} + +func (m model) mainView() string { var tabs []string for i, t := range m.tabs { if i == m.activeTab { @@ -169,3 +311,11 @@ func (m model) loadTabModel(msg tea.Msg) (tea.Model, tea.Cmd) { } return nil, nil } + +func DeepCopy(src, dst interface{}) error { + data, err := json.Marshal(src) + if err != nil { + return err + } + return json.Unmarshal(data, dst) +} diff --git a/src/internal/tui/model_test.go b/src/internal/tui/model_test.go index 3c8ea47b..eb2d72e2 100644 --- a/src/internal/tui/model_test.go +++ b/src/internal/tui/model_test.go @@ -8,36 +8,26 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/exp/teatest" - oscalTypes_1_1_2 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-2" + "github.com/defenseunicorns/lula/src/internal/testhelpers" "github.com/defenseunicorns/lula/src/internal/tui" "github.com/defenseunicorns/lula/src/internal/tui/common" - "github.com/defenseunicorns/lula/src/pkg/common/oscal" "github.com/muesli/termenv" ) +const timeout = time.Second * 10 + func init() { lipgloss.SetColorProfile(termenv.Ascii) tea.Sequence() } -func oscalFromPath(t *testing.T, path string) *oscalTypes_1_1_2.OscalCompleteSchema { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("error reading file: %v", err) - } - oscalModel, err := oscal.NewOscalModel(data) - if err != nil { - t.Fatalf("error creating oscal model from file: %v", err) - } - - return oscalModel -} - // TestNewComponentDefinitionModel tests that the NewOSCALModel creates the expected model from component definition file func TestNewComponentDefinitionModel(t *testing.T) { - oscalModel := oscalFromPath(t, "../../test/unit/common/oscal/valid-component.yaml") - model := tui.NewOSCALModel(oscalModel) + tempLog := testhelpers.CreateTempFile(t, "log") + defer os.Remove(tempLog.Name()) + + oscalModel := testhelpers.OscalFromPath(t, "../../test/unit/common/oscal/valid-component.yaml") + model := tui.NewOSCALModel(oscalModel, "", tempLog) testModel := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(common.DefaultWidth, common.DefaultHeight)) @@ -49,7 +39,7 @@ func TestNewComponentDefinitionModel(t *testing.T) { t.Fatal("testModel is nil") } - fm := testModel.FinalModel(t, teatest.WithFinalTimeout(time.Second*5)) + fm := testModel.FinalModel(t, teatest.WithFinalTimeout(timeout)) teatest.RequireEqualOutput(t, []byte(fm.View())) } @@ -57,8 +47,11 @@ func TestNewComponentDefinitionModel(t *testing.T) { // TestMultiComponentDefinitionModel tests that the NewOSCALModel creates the expected model from component definition file // and checks the component selection overlay -> new component section func TestMultiComponentDefinitionModel(t *testing.T) { - oscalModel := oscalFromPath(t, "../../test/unit/common/oscal/valid-multi-component.yaml") - model := tui.NewOSCALModel(oscalModel) + tempLog := testhelpers.CreateTempFile(t, "log") + defer os.Remove(tempLog.Name()) + + oscalModel := testhelpers.OscalFromPath(t, "../../test/unit/common/oscal/valid-multi-component.yaml") + model := tui.NewOSCALModel(oscalModel, "", tempLog) testModel := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(common.DefaultWidth, common.DefaultHeight)) testModel.Send(tea.KeyMsg{Type: tea.KeyRight}) // Select component @@ -77,15 +70,18 @@ func TestMultiComponentDefinitionModel(t *testing.T) { t.Fatal("testModel is nil") } - fm := testModel.FinalModel(t, teatest.WithFinalTimeout(time.Second*5)) + fm := testModel.FinalModel(t, teatest.WithFinalTimeout(timeout)) teatest.RequireEqualOutput(t, []byte(fm.View())) } // TestNewAssessmentResultsModel tests that the NewOSCALModel creates the expected model from assessment results file func TestNewAssessmentResultsModel(t *testing.T) { - oscalModel := oscalFromPath(t, "../../test/unit/common/oscal/valid-assessment-results.yaml") - model := tui.NewOSCALModel(oscalModel) + tempLog := testhelpers.CreateTempFile(t, "log") + defer os.Remove(tempLog.Name()) + + oscalModel := testhelpers.OscalFromPath(t, "../../test/unit/common/oscal/valid-assessment-results.yaml") + model := tui.NewOSCALModel(oscalModel, "", tempLog) testModel := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(common.DefaultWidth, common.DefaultHeight)) testModel.Send(tea.KeyMsg{Type: tea.KeyTab}) @@ -98,7 +94,7 @@ func TestNewAssessmentResultsModel(t *testing.T) { t.Fatal("testModel is nil") } - fm := testModel.FinalModel(t, teatest.WithFinalTimeout(time.Second*5)) + fm := testModel.FinalModel(t, teatest.WithFinalTimeout(timeout)) teatest.RequireEqualOutput(t, []byte(fm.View())) } @@ -106,8 +102,11 @@ func TestNewAssessmentResultsModel(t *testing.T) { // TestComponentControlSelect tests that the user can navigate to a control, select it, and see expected // remarks, description, and validations func TestComponentControlSelect(t *testing.T) { - oscalModel := oscalFromPath(t, "../../test/unit/common/oscal/valid-component.yaml") - model := tui.NewOSCALModel(oscalModel) + tempLog := testhelpers.CreateTempFile(t, "log") + defer os.Remove(tempLog.Name()) + + oscalModel := testhelpers.OscalFromPath(t, "../../test/unit/common/oscal/valid-component.yaml") + model := tui.NewOSCALModel(oscalModel, "", tempLog) testModel := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(common.DefaultWidth, common.DefaultHeight)) // Navigate to the control @@ -124,7 +123,43 @@ func TestComponentControlSelect(t *testing.T) { t.Fatal("testModel is nil") } - fm := testModel.FinalModel(t, teatest.WithFinalTimeout(time.Second*5)) + fm := testModel.FinalModel(t, teatest.WithFinalTimeout(timeout)) + + teatest.RequireEqualOutput(t, []byte(fm.View())) +} + +// TestEditViewComponentDefinitionModel tests that the editing views of the component definition model are correct +func TestEditViewComponentDefinitionModel(t *testing.T) { + tempLog := testhelpers.CreateTempFile(t, "log") + defer os.Remove(tempLog.Name()) + tempOscalFile := testhelpers.CreateTempFile(t, "yaml") + defer os.Remove(tempOscalFile.Name()) + + oscalModel := testhelpers.OscalFromPath(t, "../../test/unit/common/oscal/valid-component.yaml") + model := tui.NewOSCALModel(oscalModel, tempOscalFile.Name(), tempLog) + + testModel := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(common.DefaultWidth, common.DefaultHeight)) + + // Edit the remarks + testModel.Send(tea.KeyMsg{Type: tea.KeyRight}) // Select component + testModel.Send(tea.KeyMsg{Type: tea.KeyRight}) // Select framework + testModel.Send(tea.KeyMsg{Type: tea.KeyRight}) // Select control + testModel.Send(tea.KeyMsg{Type: tea.KeyEnter}) // Open control + testModel.Send(tea.KeyMsg{Type: tea.KeyRight}) // Navigate to remarks + testModel.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) // Edit remarks + testModel.Send(tea.KeyMsg{Type: tea.KeyCtrlE}) // Newline + testModel.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t', 'e', 's', 't'}}) // Add "test" to remarks + testModel.Send(tea.KeyMsg{Type: tea.KeyEnter}) // Open control + + if err := testModel.Quit(); err != nil { + t.Fatal(err) + } + + if testModel == nil { + t.Fatal("testModel is nil") + } + + fm := testModel.FinalModel(t, teatest.WithFinalTimeout(timeout)) teatest.RequireEqualOutput(t, []byte(fm.View())) } diff --git a/src/internal/tui/testdata/TestComponentControlSelect.golden b/src/internal/tui/testdata/TestComponentControlSelect.golden index c91d307d..921e81ae 100644 --- a/src/internal/tui/testdata/TestComponentControlSelect.golden +++ b/src/internal/tui/testdata/TestComponentControlSelect.golden @@ -1,7 +1,7 @@ ╭─────────────────────╮╭───────────────────╮╭────────────────────╮╭────────────────╮╭───────────────────────────╮╭─────────╮╭─────────╮ │ ComponentDefinition ││ AssessmentResults ││ SystemSecurityPlan ││ AssessmentPlan ││ PlanOfActionAndMilestones ││ Catalog ││ Profile │ ┘ └┴───────────────────┴┴────────────────────┴┴────────────────┴┴───────────────────────────┴┴─────────┴┴─────────┴───────────────────────────────────────────────────────────────── - ←/h, →/l navigation • ? toggle help + ↳ select • ↑/k move up • ↓/j move down • / filter • ? toggle help ╭────────────────────────────────────────╮ ╭────────────────────────────────────────╮ Selected Component │lula - A9D5204C-7E5B-4C43-BD49-34DF759B…│ Selected Framework │https://github.com/defenseunicorns/lula │ ╰────────────────────────────────────────╯ ╰────────────────────────────────────────╯ @@ -55,5 +55,5 @@ │ │ │ │ │ │ │ │ │ │ │ │ - │ ↑/k up • ↓/j down • enter select • ? │ │ enter select • ? toggle help │ + │ │ │ │ ╰────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/internal/tui/testdata/TestEditViewComponentDefinitionModel.golden b/src/internal/tui/testdata/TestEditViewComponentDefinitionModel.golden new file mode 100644 index 00000000..3480acee --- /dev/null +++ b/src/internal/tui/testdata/TestEditViewComponentDefinitionModel.golden @@ -0,0 +1,59 @@ +╭─────────────────────╮╭───────────────────╮╭────────────────────╮╭────────────────╮╭───────────────────────────╮╭─────────╮╭─────────╮ +│ ComponentDefinition ││ AssessmentResults ││ SystemSecurityPlan ││ AssessmentPlan ││ PlanOfActionAndMilestones ││ Catalog ││ Profile │ +┘ └┴───────────────────┴┴────────────────────┴┴────────────────┴┴───────────────────────────┴┴─────────┴┴─────────┴───────────────────────────────────────────────────────────────── + e edit • ctrl+s save • ←/h, →/l navigation • tab/shift+tab switch models • ? toggle help + ╭────────────────────────────────────────╮ ╭────────────────────────────────────────╮ + Selected Component │lula - A9D5204C-7E5B-4C43-BD49-34DF759B…│ Selected Framework │https://github.com/defenseunicorns/lula │ + ╰────────────────────────────────────────╯ ╰────────────────────────────────────────╯ + ╭───────────────╮ ╭─────────╮ + │ Controls List ├─────────────────────────────╮ │ Remarks ├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + ╰───────────────╯ ╰─────────╯ + │ │ │ Here are some remarks about this control. │ + │ 1 item │ │ test │ + │ │ │ │ + │ │ ID-1 │ │ │ + │ │ 42C2FFDC-5F05-44DF-A67F-EEC8660AEFFD │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + │ │ ╭─────────────╮ + │ │ │ Description ├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ ╰─────────────╯ + │ │ │ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim │ + │ │ │ veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in │ + │ │ │ voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia │ + │ │ │ deserunt mollit anim id est laborum. │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + │ │ ╭─────────────╮ + │ │ │ Validations ├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ ╰─────────────╯ + │ │ │ │ + │ │ │ 1 item │ + │ │ │ │ + │ │ │ Validate pods with label foo=bar │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + │ │ │ │ + ╰────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/internal/tui/testdata/TestMultiComponentDefinitionModel.golden b/src/internal/tui/testdata/TestMultiComponentDefinitionModel.golden index 323cc41d..2eecc66d 100644 --- a/src/internal/tui/testdata/TestMultiComponentDefinitionModel.golden +++ b/src/internal/tui/testdata/TestMultiComponentDefinitionModel.golden @@ -1,24 +1,24 @@ ╭─────────────────────╮╭───────────────────╮╭────────────────────╮╭────────────────╮╭───────────────────────────╮╭─────────╮╭─────────╮ │ ComponentDefinition ││ AssessmentResults ││ SystemSecurityPlan ││ AssessmentPlan ││ PlanOfActionAndMilestones ││ Catalog ││ Profile │ ┘ └┴───────────────────┴┴────────────────────┴┴────────────────┴┴───────────────────────────┴┴─────────┴┴─────────┴───────────────────────────────────────────────────────────────── - ←/h, →/l navigation • ? toggle help + ↳ select • ↑/k move up • ↓/j move down • / filter • ? toggle help ╭────────────────────────────────────────╮ ╭────────────────────────────────────────╮ - Selected Component │Component B - 4cb1810c-d0d8-404e-b346-5…│ Selected Framework │https://raw.githubusercontent.com/usnis…│ + Selected Component │Component A - 7c02500a-6e33-44e0-82ee-f…│ Selected Framework │https://raw.githubusercontent.com/usnis…│ ╰────────────────────────────────────────╯ ╰────────────────────────────────────────╯ ╭───────────────╮ ╭─────────╮ │ Controls List ├─────────────────────────────╮ │ Remarks ├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ ╰───────────────╯ ╰─────────╯ │ │ │ STATEMENT: │ - │ 3 items │ │ The information system enforces approved authorizations for controlling the flow of information within the system and between interconnected │ - │ │ │ systems based on [Assignment: organization-defined organization-defined information flow control policies]. │ - │ │ ac-4 │ │ │ - │ │ ea9f3b4d-64c2-4631-ace5-55428552f9aa │ │ │ - │ │ │ │ - │ ac-5 │ │ │ - │ 1976b301-115f-48a4-b847-3374aa3b98d5 │ │ │ - │ │ │ │ - │ ac-6 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - │ be129429-290b-4516-9390-f4d38067fbd0 │ ╭─────────────╮ + │ 3 items │ │ The organization:a. Develops, documents, and disseminates to [Assignment: organization-defined organization-defined personnel or roles]: │ + │ │ │ 1. An access control policy that addresses purpose, scope, roles, responsibilities, management commitment, coordination among │ + │ │ ac-1 │ │ organizational entities, and compliance; and │ + │ │ 67dd59c4-0340-4aed-a49d-002815b50157 │ │ 2. Procedures to facilitate the implementation of the access control policy and associated access controls; and │ + │ │ │ b. Reviews and updates the current: │ + │ ac-2 │ │ 1. Access control policy [Assignment: organization-defined organization-defined frequency]; and │ + │ 663e7c26-3bfe-4c71-b423-10d8338d5445 │ │ 2. Access control procedures [Assignment: organization-defined organization-defined frequency]. │ + │ │ │ │ + │ ac-3 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + │ 07e1e996-5ae7-4b0b-b4c0-01f35729e442 │ ╭─────────────╮ │ │ │ Description ├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ ╰─────────────╯ │ │ │ │ @@ -55,5 +55,5 @@ │ │ │ │ │ │ │ │ │ │ │ │ - │ ↑/k up • ↓/j down • enter select • ? │ │ enter select • ? toggle help │ + │ │ │ │ ╰────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/internal/tui/testdata/TestNewAssessmentResultsModel.golden b/src/internal/tui/testdata/TestNewAssessmentResultsModel.golden index 8d49fc5c..e838673b 100644 --- a/src/internal/tui/testdata/TestNewAssessmentResultsModel.golden +++ b/src/internal/tui/testdata/TestNewAssessmentResultsModel.golden @@ -1,7 +1,7 @@ ╭─────────────────────╮╭───────────────────╮╭────────────────────╮╭────────────────╮╭───────────────────────────╮╭─────────╮╭─────────╮ │ ComponentDefinition ││ AssessmentResults ││ SystemSecurityPlan ││ AssessmentPlan ││ PlanOfActionAndMilestones ││ Catalog ││ Profile │ ┴─────────────────────┴┘ └┴────────────────────┴┴────────────────┴┴───────────────────────────┴┴─────────┴┴─────────┴───────────────────────────────────────────────────────────────── - v validate • e evaluate • ? toggle help + ? toggle help ╭────────────────────────────────────────╮ ╭────────────────────────────────────────╮ Selected Result │Lula Validation Result - 41787700-2a4c-…│ Compare Result │No Result Selected │ ╰────────────────────────────────────────╯ ╰────────────────────────────────────────╯ @@ -55,5 +55,5 @@ │ │ │ │ │ │ │ │ │ │ │ │ - │ ↑/k up • ↓/j down • enter select • ? │ │ │ + │ ↑/k up • ↓/j down • ↳ confirm • ? toggle │ │ │ ╰────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/internal/tui/testdata/TestNewComponentDefinitionModel.golden b/src/internal/tui/testdata/TestNewComponentDefinitionModel.golden index c4100910..67948eb3 100644 --- a/src/internal/tui/testdata/TestNewComponentDefinitionModel.golden +++ b/src/internal/tui/testdata/TestNewComponentDefinitionModel.golden @@ -1,7 +1,7 @@ ╭─────────────────────╮╭───────────────────╮╭────────────────────╮╭────────────────╮╭───────────────────────────╮╭─────────╮╭─────────╮ │ ComponentDefinition ││ AssessmentResults ││ SystemSecurityPlan ││ AssessmentPlan ││ PlanOfActionAndMilestones ││ Catalog ││ Profile │ ┘ └┴───────────────────┴┴────────────────────┴┴────────────────┴┴───────────────────────────┴┴─────────┴┴─────────┴───────────────────────────────────────────────────────────────── - ←/h, →/l navigation • ? toggle help + ←/h, →/l navigation • tab/shift+tab switch models • ? toggle help ╭────────────────────────────────────────╮ ╭────────────────────────────────────────╮ Selected Component │lula - A9D5204C-7E5B-4C43-BD49-34DF759B…│ Selected Framework │https://github.com/defenseunicorns/lula │ ╰────────────────────────────────────────╯ ╰────────────────────────────────────────╯ @@ -55,5 +55,5 @@ │ │ │ │ │ │ │ │ │ │ │ │ - │ ↑/k up • ↓/j down • enter select • ? │ │ enter select • ? toggle help │ + │ │ │ │ ╰────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/src/pkg/common/oscal/common.go b/src/pkg/common/oscal/common.go index 2fd798a6..799c0f29 100644 --- a/src/pkg/common/oscal/common.go +++ b/src/pkg/common/oscal/common.go @@ -91,6 +91,38 @@ func CompareControls(a, b string) bool { return !isANistFormat } +// CompareControlsInt compares two controls by their title, handling both XX-##.## formats and regular strings. +// returns -1 if a < b, 0 if a == b, and 1 if a > b +// TODO: add tests for this function +func CompareControlsInt(a, b string) int { + // Define a regex to match the XX-##.## format + nistFormat := regexp.MustCompile(`(?i)^[a-z]{2}-\d+(\.\d+)?$`) + + // Check if both strings match the XX-##.## format + isANistFormat := nistFormat.MatchString(a) + isBNistFormat := nistFormat.MatchString(b) + + // If both are in XX-##.## format, apply the custom comparison logic + if isANistFormat && isBNistFormat { + if compareNistFormat(a, b) { + return -1 + } else { + return 1 + } + } + + // If neither are in XX-##.## format, use simple lexicographical comparison + if !isANistFormat && !isBNistFormat { + return strings.Compare(a, b) + } + + // If only one is in XX-##.## format, treat it as "less than" the regular string + if isANistFormat { + return -1 + } + return 1 +} + // compareNistFormat handles the comparison for strings in the XX-##.## format. func compareNistFormat(a, b string) bool { // Split the strings by "-" diff --git a/src/pkg/common/oscal/complete-schema.go b/src/pkg/common/oscal/complete-schema.go index 2fe5dbcd..0d768f62 100644 --- a/src/pkg/common/oscal/complete-schema.go +++ b/src/pkg/common/oscal/complete-schema.go @@ -106,6 +106,46 @@ func WriteOscalModel(filePath string, model *oscalTypes_1_1_2.OscalModels) error } +// OverwriteOscalModel takes a path and writes content to a file - does not check for existing content +// supports both json and yaml +func OverwriteOscalModel(filePath string, model *oscalTypes_1_1_2.OscalModels) error { + + // if no path or directory add default filename + if filepath.Ext(filePath) == "" { + filePath = filepath.Join(filePath, fmt.Sprintf("%s.yaml", "oscal")) + } else { + if err := files.IsJsonOrYaml(filePath); err != nil { + return err + } + } + + // Make deterministic + if model.ComponentDefinition != nil { + MakeComponentDeterminstic(model.ComponentDefinition) + } + if model.AssessmentResults != nil { + MakeAssessmentResultsDeterministic(model.AssessmentResults) + } + var b bytes.Buffer + + if filepath.Ext(filePath) == ".json" { + jsonEncoder := json.NewEncoder(&b) + jsonEncoder.SetIndent("", " ") + jsonEncoder.Encode(model) + } else { + yamlEncoder := yamlV3.NewEncoder(&b) + yamlEncoder.SetIndent(2) + yamlEncoder.Encode(model) + } + + if err := files.WriteOutput(b.Bytes(), filePath); err != nil { + return err + } + + return nil + +} + func MergeOscalModels(existingModel *oscalTypes_1_1_2.OscalModels, newModel *oscalTypes_1_1_2.OscalModels, modelType string) (*oscalTypes_1_1_2.OscalModels, error) { var err error // Now to check each model type - currently only component definition and assessment-results apply diff --git a/src/pkg/common/oscal/component.go b/src/pkg/common/oscal/component.go index de803e23..eccac3ee 100644 --- a/src/pkg/common/oscal/component.go +++ b/src/pkg/common/oscal/component.go @@ -530,39 +530,6 @@ func FilterControlImplementations(componentDefinition *oscalTypes_1_1_2.Componen return controlMap } -// Need to get components + frameworks + control implementations -> new struct? -type ComponentFrameworks struct { - Component oscalTypes_1_1_2.DefinedComponent - Frameworks map[string][]oscalTypes_1_1_2.ControlImplementationSet -} - -func NewComponentFrameworks(componentDefinition *oscalTypes_1_1_2.ComponentDefinition) map[string]ComponentFrameworks { - componentTargets := make(map[string]ComponentFrameworks) - - if componentDefinition.Components != nil { - // Build a map[source/framework][]control-implementations - for _, component := range *componentDefinition.Components { - controlImplementationsMap := make(map[string][]oscalTypes_1_1_2.ControlImplementationSet) - if component.ControlImplementations != nil { - for _, controlImplementation := range *component.ControlImplementations { - // Using UUID here as the key -> could also be string -> what would we rather the user pass in? - controlImplementationsMap[controlImplementation.Source] = append(controlImplementationsMap[controlImplementation.Source], controlImplementation) - status, value := GetProp("framework", LULA_NAMESPACE, controlImplementation.Props) - if status { - controlImplementationsMap[value] = append(controlImplementationsMap[value], controlImplementation) - } - } - } - componentTargets[component.UUID] = ComponentFrameworks{ - Component: component, - Frameworks: controlImplementationsMap, - } - } - } - - return componentTargets -} - func MakeComponentDeterminstic(component *oscalTypes_1_1_2.ComponentDefinition) { // sort components by title diff --git a/src/pkg/common/oscal/component_test.go b/src/pkg/common/oscal/component_test.go index 591c9f8f..40ae279d 100644 --- a/src/pkg/common/oscal/component_test.go +++ b/src/pkg/common/oscal/component_test.go @@ -559,21 +559,3 @@ func TestFilterControlImplementations(t *testing.T) { }) } } - -func TestNewComponentFrameworks(t *testing.T) { - t.Parallel() - validBytes := loadTestData(t, "../../../test/unit/common/oscal/valid-multi-component.yaml") - validComponent, _ := oscal.NewOscalComponentDefinition(validBytes) - - t.Run("It populates a componentFrameworks map", func(t *testing.T) { - componentFrameworks := oscal.NewComponentFrameworks(validComponent) - if len(componentFrameworks) != 2 { - t.Errorf("Expected 2 componentFrameworks, got %v", len(componentFrameworks)) - } - for _, c := range componentFrameworks { - if len(c.Frameworks) != 4 { - t.Errorf("Expected 4 targets in each framework, got %v", len(c.Frameworks)) - } - } - }) -} diff --git a/src/test/unit/common/oscal/valid-multi-component-validations.yaml b/src/test/unit/common/oscal/valid-multi-component-validations.yaml new file mode 100644 index 00000000..7cbcff7e --- /dev/null +++ b/src/test/unit/common/oscal/valid-multi-component-validations.yaml @@ -0,0 +1,246 @@ +component-definition: + components: + - control-implementations: + - description: Control Implementation Description + implemented-requirements: + - control-id: ac-1 + description: + remarks: | + STATEMENT: + The organization:a. Develops, documents, and disseminates to [Assignment: organization-defined organization-defined personnel or roles]: + 1. An access control policy that addresses purpose, scope, roles, responsibilities, management commitment, coordination among organizational entities, and compliance; and + 2. Procedures to facilitate the implementation of the access control policy and associated access controls; and + b. Reviews and updates the current: + 1. Access control policy [Assignment: organization-defined organization-defined frequency]; and + 2. Access control procedures [Assignment: organization-defined organization-defined frequency]. + uuid: 67dd59c4-0340-4aed-a49d-002815b50157 + links: + - href: "#88AB3470-B96B-4D7C-BC36-02BF9563C46C" + rel: lula + text: Sample Validation 1 + - href: "#01e21994-2cfc-45fb-ac84-d00f2e5912b0" + rel: lula + text: Sample Validation 2 + - control-id: ac-2 + description: + remarks: | + STATEMENT: + The organization:a. Identifies and selects the following types of information system accounts to support organizational missions/business functions: [Assignment: organization-defined organization-defined information system account types]; + b. Assigns account managers for information system accounts; + c. Establishes conditions for group and role membership; + d. Specifies authorized users of the information system, group and role membership, and access authorizations (i.e., privileges) and other attributes (as required) for each account; + e. Requires approvals by [Assignment: organization-defined organization-defined personnel or roles] for requests to create information system accounts; + f. Creates, enables, modifies, disables, and removes information system accounts in accordance with [Assignment: organization-defined organization-defined procedures or conditions]; + g. Monitors the use of information system accounts; + h. Notifies account managers: + 1. When accounts are no longer required; + 2. When users are terminated or transferred; and + 3. When individual information system usage or need-to-know changes; + i. Authorizes access to the information system based on: + 1. A valid access authorization; + 2. Intended system usage; and + 3. Other attributes as required by the organization or associated missions/business functions; + j. Reviews accounts for compliance with account management requirements [Assignment: organization-defined organization-defined frequency]; and + k. Establishes a process for reissuing shared/group account credentials (if deployed) when individuals are removed from the group. + uuid: 663e7c26-3bfe-4c71-b423-10d8338d5445 + links: + - href: "#88AB3470-B96B-4D7C-BC36-02BF9563C46C" + rel: lula + text: Sample Validation 1 + - control-id: ac-3 + description: + remarks: |- + STATEMENT: + The information system enforces approved authorizations for logical access to information and system resources in accordance with applicable access control policies. + uuid: 07e1e996-5ae7-4b0b-b4c0-01f35729e442 + links: + - href: "#01e21994-2cfc-45fb-ac84-d00f2e5912b0" + rel: lula + text: Sample Validation 2 + source: https://raw.githubusercontent.com/usnistgov/oscal-content/main/nist.gov/SP800-53/rev4/yaml/NIST_SP-800-53_rev4_HIGH-baseline-resolved-profile_catalog.yaml + uuid: 0631b5b8-e51a-577b-8a43-2d3d0bd9ced8 + props: + - name: framework + ns: https://docs.lula.dev/ns + value: rev4 + - description: Control Implementation Description + implemented-requirements: + - control-id: ac-1 + description: + remarks: | + STATEMENT: + a. Develop, document, and disseminate to [Assignment: organization-defined organization-defined personnel or roles]: + 1. [Selection: (one-or-more) organization-defined organization-level; mission/business process-level; system-level] access control policy that: + (a) Addresses purpose, scope, roles, responsibilities, management commitment, coordination among organizational entities, and compliance; and + (b) Is consistent with applicable laws, executive orders, directives, regulations, policies, standards, and guidelines; and + 2. Procedures to facilitate the implementation of the access control policy and the associated access controls; + b. Designate an [Assignment: organization-defined official] to manage the development, documentation, and dissemination of the access control policy and procedures; and + c. Review and update the current access control: + 1. Policy [Assignment: organization-defined frequency] and following [Assignment: organization-defined events] ; and + 2. Procedures [Assignment: organization-defined frequency] and following [Assignment: organization-defined events]. + uuid: 857121b1-2992-412c-b34a-504ead86e117 + - control-id: ac-2 + description: + remarks: | + STATEMENT: + a. Define and document the types of accounts allowed and specifically prohibited for use within the system; + b. Assign account managers; + c. Require [Assignment: organization-defined prerequisites and criteria] for group and role membership; + d. Specify: + 1. Authorized users of the system; + 2. Group and role membership; and + 3. Access authorizations (i.e., privileges) and [Assignment: organization-defined attributes (as required)] for each account; + e. Require approvals by [Assignment: organization-defined personnel or roles] for requests to create accounts; + f. Create, enable, modify, disable, and remove accounts in accordance with [Assignment: organization-defined policy, procedures, prerequisites, and criteria]; + g. Monitor the use of accounts; + h. Notify account managers and [Assignment: organization-defined personnel or roles] within: + 1. [Assignment: organization-defined time period] when accounts are no longer required; + 2. [Assignment: organization-defined time period] when users are terminated or transferred; and + 3. [Assignment: organization-defined time period] when system usage or need-to-know changes for an individual; + i. Authorize access to the system based on: + 1. A valid access authorization; + 2. Intended system usage; and + 3. [Assignment: organization-defined attributes (as required)]; + j. Review accounts for compliance with account management requirements [Assignment: organization-defined frequency]; + k. Establish and implement a process for changing shared or group account authenticators (if deployed) when individuals are removed from the group; and + l. Align account management processes with personnel termination and transfer processes. + uuid: c93c7d6a-e276-4050-9a54-437f7903b39e + - control-id: ac-3 + description: + remarks: |- + STATEMENT: + Enforce approved authorizations for logical access to information and system resources in accordance with applicable access control policies. + uuid: fc1bb76a-4ca5-4832-8cf9-6df509d3c996 + source: https://raw.githubusercontent.com/usnistgov/oscal-content/main/nist.gov/SP800-53/rev5/yaml/NIST_SP-800-53_rev5_HIGH-baseline-resolved-profile_catalog.yaml + uuid: b1723ecd-a15a-5daf-a8e0-a7dd20a19abf + props: + - name: framework + ns: https://docs.lula.dev/ns + value: rev5 + description: Component Description + remarks: |- + This component was generated using the following command: + lula generate component --catalog-source https://raw.githubusercontent.com/usnistgov/oscal-content/main/nist.gov/SP800-53/rev5/yaml/NIST_SP-800-53_rev5_HIGH-baseline-resolved-profile_catalog.yaml --component 'Component A' --requirements ac-1,ac-2,ac-3 --remarks statement + title: Component A + type: software + uuid: 7c02500a-6e33-44e0-82ee-fba0f5ea0cae + - control-implementations: + - description: Control Implementation Description + implemented-requirements: + - control-id: ac-4 + description: + remarks: |- + STATEMENT: + The information system enforces approved authorizations for controlling the flow of information within the system and between interconnected systems based on [Assignment: organization-defined organization-defined information flow control policies]. + uuid: ea9f3b4d-64c2-4631-ace5-55428552f9aa + - control-id: ac-5 + description: + remarks: | + STATEMENT: + The organization:a. Separates [Assignment: organization-defined organization-defined duties of individuals]; + b. Documents separation of duties of individuals; and + c. Defines information system access authorizations to support separation of duties. + uuid: 1976b301-115f-48a4-b847-3374aa3b98d5 + - control-id: ac-6 + description: + remarks: |- + STATEMENT: + The organization employs the principle of least privilege, allowing only authorized accesses for users (or processes acting on behalf of users) which are necessary to accomplish assigned tasks in accordance with organizational missions and business functions. + uuid: be129429-290b-4516-9390-f4d38067fbd0 + source: https://raw.githubusercontent.com/usnistgov/oscal-content/main/nist.gov/SP800-53/rev4/yaml/NIST_SP-800-53_rev4_HIGH-baseline-resolved-profile_catalog.yaml + uuid: 0631b5b8-e51a-577b-8a43-2d3d0bd9ced8 + props: + - name: framework + ns: https://docs.lula.dev/ns + value: rev4 + - description: Control Implementation Description + implemented-requirements: + - control-id: ac-4 + description: + remarks: |- + STATEMENT: + Enforce approved authorizations for controlling the flow of information within the system and between connected systems based on [Assignment: organization-defined information flow control policies]. + uuid: 81a78740-c3ac-4ee3-a1e5-81944dd21b6c + - control-id: ac-5 + description: + remarks: | + STATEMENT: + a. Identify and document [Assignment: organization-defined duties of individuals] ; and + b. Define system access authorizations to support separation of duties. + uuid: 0649ec59-757a-483e-b8a9-bb4b7fbc4d6e + - control-id: ac-6 + description: + remarks: |- + STATEMENT: + Employ the principle of least privilege, allowing only authorized accesses for users (or processes acting on behalf of users) that are necessary to accomplish assigned organizational tasks. + uuid: 45b56420-3689-431d-8811-ec9bc648f64e + source: https://raw.githubusercontent.com/usnistgov/oscal-content/main/nist.gov/SP800-53/rev5/yaml/NIST_SP-800-53_rev5_HIGH-baseline-resolved-profile_catalog.yaml + uuid: b1723ecd-a15a-5daf-a8e0-a7dd20a19abf + props: + - name: framework + ns: https://docs.lula.dev/ns + value: rev5 + description: Component Description + remarks: |- + This component was generated using the following command: + lula generate component --catalog-source https://raw.githubusercontent.com/usnistgov/oscal-content/main/nist.gov/SP800-53/rev5/yaml/NIST_SP-800-53_rev5_HIGH-baseline-resolved-profile_catalog.yaml --component 'Component B' --requirements ac-4,ac-5,ac-6 --remarks statement + title: Component B + type: software + uuid: 4cb1810c-d0d8-404e-b346-5a12c9629ed5 + metadata: + last-modified: 2024-07-29T20:55:22.308602323Z + oscal-version: 1.1.2 + published: 2024-07-29T20:54:20.414438777Z + remarks: Lula Generated Component Definition + title: Component Title + version: 0.0.1 + uuid: 5b4b2ac2-50a1-4cba-99bb-82f92953f5c5 + back-matter: + resources: + - uuid: 88AB3470-B96B-4D7C-BC36-02BF9563C46C + description: >- + domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podsvt + resource-rule: + version: v1 + resource: pods + namespaces: [validation-test] + provider: + type: opa + opa-spec: + rego: | + package validate + + import future.keywords.every + + validate { + every pod in input.podsvt { + podLabel := pod.metadata.labels.foo + podLabel == "bar" + } + } + - uuid: 01e21994-2cfc-45fb-ac84-d00f2e5912b0 + description: >- + domain: + type: kubernetes + kubernetes-spec: + resources: + - name: podvt + resource-rule: + name: test-pod-label + version: v1 + resource: pods + namespaces: [validation-test] + provider: + type: opa + opa-spec: + rego: | + package validate + + validate { + podLabel := input.podvt.metadata.labels.foo + podLabel == "bar" + } \ No newline at end of file