mirror of
https://github.com/chenasraf/sofmani.git
synced 2026-05-17 17:28:04 +00:00
feat: add categories
This commit is contained in:
14
README.md
14
README.md
@@ -20,6 +20,7 @@ reproducible.
|
||||
- Configurable **platform-specific behaviors**.
|
||||
- Automatic software updates using custom logic.
|
||||
- Group software installations into logical "steps" with sophisticated orchestration.
|
||||
- **Category headers** to visually organize your installers list.
|
||||
|
||||
---
|
||||
|
||||
@@ -162,6 +163,19 @@ For a full breakdown with all the supported options, see
|
||||
The `install` field describes the steps to execute. Each step represents an action or group of
|
||||
actions. Steps can be of **several types**, such as `brew`, `rsync`, `shell`, and more.
|
||||
|
||||
You can also add **category headers** to organize your installers visually:
|
||||
|
||||
```yaml
|
||||
install:
|
||||
- category: Development Tools
|
||||
desc: Optional description for the category.
|
||||
|
||||
- name: neovim
|
||||
type: brew
|
||||
```
|
||||
|
||||
See [Installer Configuration](./docs/installer-configuration.md#categories) for more details.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `name` | String (required) | Identifier for the step. It does not have to be unique, but is usually used to check for the app's existence, if applicable (can be overridden using `bin_name`) |
|
||||
|
||||
@@ -44,6 +44,11 @@ func (s *SkipSummary) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
|
||||
// InstallerData represents the configuration for a single installer.
|
||||
type InstallerData struct {
|
||||
// Category is a special field for visual organization. When set, this entry only displays
|
||||
// a bordered header and does not perform any installation.
|
||||
Category *string `json:"category" yaml:"category"`
|
||||
// Desc is an optional description shown below the category name in the bordered header.
|
||||
Desc *string `json:"desc" yaml:"desc"`
|
||||
// Enabled determines if the installer is enabled. Can be a boolean string ("true", "false") or a condition.
|
||||
Enabled *string `json:"enabled" yaml:"enabled"`
|
||||
// Name is the name of the installer.
|
||||
@@ -118,3 +123,8 @@ func (i *InstallerData) GetTagsList() []string {
|
||||
return strings.TrimSpace(tag)
|
||||
})
|
||||
}
|
||||
|
||||
// IsCategory returns true if this entry is a category header (not an actual installer).
|
||||
func (i *InstallerData) IsCategory() bool {
|
||||
return i.Category != nil && len(*i.Category) > 0
|
||||
}
|
||||
|
||||
@@ -213,3 +213,69 @@ func TestSkipSummary_UnmarshalYAML(t *testing.T) {
|
||||
func parseYAML(yamlStr string, v any) error {
|
||||
return yamlPkg.Unmarshal([]byte(yamlStr), v)
|
||||
}
|
||||
|
||||
func TestInstallerData_IsCategory(t *testing.T) {
|
||||
t.Run("returns true when Category is set", func(t *testing.T) {
|
||||
category := "Development Tools"
|
||||
data := &InstallerData{
|
||||
Category: &category,
|
||||
}
|
||||
assert.True(t, data.IsCategory())
|
||||
})
|
||||
|
||||
t.Run("returns false when Category is nil", func(t *testing.T) {
|
||||
data := &InstallerData{}
|
||||
assert.False(t, data.IsCategory())
|
||||
})
|
||||
|
||||
t.Run("returns false when Category is empty string", func(t *testing.T) {
|
||||
category := ""
|
||||
data := &InstallerData{
|
||||
Category: &category,
|
||||
}
|
||||
assert.False(t, data.IsCategory())
|
||||
})
|
||||
|
||||
t.Run("category parsed from YAML", func(t *testing.T) {
|
||||
yaml := `category: System Utilities`
|
||||
var data InstallerData
|
||||
err := parseYAML(yaml, &data)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, data.IsCategory())
|
||||
assert.Equal(t, "System Utilities", *data.Category)
|
||||
})
|
||||
|
||||
t.Run("category with desc parsed from YAML", func(t *testing.T) {
|
||||
yaml := `category: System Utilities
|
||||
desc: These are system tools.`
|
||||
var data InstallerData
|
||||
err := parseYAML(yaml, &data)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, data.IsCategory())
|
||||
assert.Equal(t, "System Utilities", *data.Category)
|
||||
assert.Equal(t, "These are system tools.", *data.Desc)
|
||||
})
|
||||
|
||||
t.Run("category with multiline desc parsed from YAML", func(t *testing.T) {
|
||||
yaml := `category: Development
|
||||
desc: |
|
||||
First line.
|
||||
Second line.`
|
||||
var data InstallerData
|
||||
err := parseYAML(yaml, &data)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, data.IsCategory())
|
||||
assert.Equal(t, "Development", *data.Category)
|
||||
assert.Contains(t, *data.Desc, "First line.")
|
||||
assert.Contains(t, *data.Desc, "Second line.")
|
||||
})
|
||||
|
||||
t.Run("regular installer is not a category", func(t *testing.T) {
|
||||
yaml := `name: test
|
||||
type: shell`
|
||||
var data InstallerData
|
||||
err := parseYAML(yaml, &data)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, data.IsCategory())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,79 @@
|
||||
The `install` field describes the steps to execute. Each step represents an action or group of
|
||||
actions. Steps can be of **several types**, such as `brew`, `rsync`, `shell`, and more.
|
||||
|
||||
## Categories
|
||||
|
||||
You can add **category headers** to visually organize your installers list. Categories are special
|
||||
entries that display a bordered header in the output but don't perform any installation.
|
||||
|
||||
### Fields
|
||||
|
||||
- **`category`**
|
||||
|
||||
- **Type**: String (required for category entries)
|
||||
- **Description**: The category name to display. When this field is present, the entry is treated
|
||||
as a category header, not an installer.
|
||||
|
||||
- **`desc`**
|
||||
|
||||
- **Type**: String (optional)
|
||||
- **Description**: An optional description shown below the category name. Supports multi-line
|
||||
text with automatic word wrapping. Existing line breaks are preserved.
|
||||
|
||||
### Example
|
||||
|
||||
```yaml
|
||||
install:
|
||||
- category: Development Tools
|
||||
|
||||
- name: neovim
|
||||
type: brew
|
||||
|
||||
- name: lazygit
|
||||
type: brew
|
||||
|
||||
- category: System Utilities
|
||||
desc: Tools for system maintenance and monitoring.
|
||||
|
||||
- name: htop
|
||||
type: brew
|
||||
|
||||
- category: Configuration
|
||||
desc: |
|
||||
These installers sync configuration files from dotfiles.
|
||||
They run on every execution to keep configs up to date.
|
||||
|
||||
- name: nvim-config
|
||||
type: rsync
|
||||
opts:
|
||||
source: ~/.dotfiles/.config/nvim
|
||||
destination: ~/.config/nvim
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
Categories are displayed with a bordered header:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Development Tools │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
With a description:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ System Utilities │
|
||||
├──────────────────────────────────────────────────────────┤
|
||||
│ Tools for system maintenance and monitoring. │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The box width adapts to narrower terminals (minimum of terminal width or 60 characters).
|
||||
|
||||
---
|
||||
|
||||
## Fields
|
||||
|
||||
These fields are shared by all installer types. Some fields may vary in behavior depending on the
|
||||
|
||||
10
go.mod
10
go.mod
@@ -1,21 +1,19 @@
|
||||
module github.com/chenasraf/sofmani
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1
|
||||
github.com/eschao/config v0.1.0
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/samber/lo v1.47.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/term v0.39.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
15
go.sum
15
go.sum
@@ -3,13 +3,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eschao/config v0.1.0 h1:vtlNamzs6dC9pE0zyplqql16PFUUlst3VttQ+IT2/rk=
|
||||
github.com/eschao/config v0.1.0/go.mod h1:XMilcx0dPfk+tlJowGZPZdmdCRnd7AZuFhYA93tYBgA=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc=
|
||||
@@ -18,10 +11,10 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
105
logger/logger.go
105
logger/logger.go
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/chenasraf/sofmani/platform"
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Highlight markers (using unlikely byte sequences)
|
||||
@@ -219,3 +220,107 @@ func CloseLogger() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Box-drawing characters for category headers
|
||||
const (
|
||||
boxTopLeft = "┌"
|
||||
boxTopRight = "┐"
|
||||
boxBottomLeft = "└"
|
||||
boxBottomRight = "┘"
|
||||
boxHorizontal = "─"
|
||||
boxVertical = "│"
|
||||
boxLeftT = "├"
|
||||
boxRightT = "┤"
|
||||
boxDefaultWidth = 60
|
||||
)
|
||||
|
||||
// getBoxWidth returns the width to use for category boxes.
|
||||
// It uses the terminal width if it's narrower than the default, otherwise uses the default.
|
||||
func getBoxWidth() int {
|
||||
width, _, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || width <= 0 {
|
||||
return boxDefaultWidth
|
||||
}
|
||||
if width < boxDefaultWidth {
|
||||
return width
|
||||
}
|
||||
return boxDefaultWidth
|
||||
}
|
||||
|
||||
// Category logs a category header with a decorative border.
|
||||
// If desc is provided, it will be displayed below the category name with auto-wrapping.
|
||||
func Category(name string, desc *string) {
|
||||
boxWidth := getBoxWidth()
|
||||
innerWidth := boxWidth - 4 // Account for "│ " and " │"
|
||||
|
||||
// Build the border lines
|
||||
horizontalLine := strings.Repeat(boxHorizontal, boxWidth-2)
|
||||
topBorder := boxTopLeft + horizontalLine + boxTopRight
|
||||
bottomBorder := boxBottomLeft + horizontalLine + boxBottomRight
|
||||
separator := boxLeftT + horizontalLine + boxRightT
|
||||
|
||||
// Log the header
|
||||
Info("")
|
||||
Info("%s", topBorder)
|
||||
Info("%s", formatBoxLine(name, innerWidth))
|
||||
|
||||
// Log description if provided
|
||||
if desc != nil && len(*desc) > 0 {
|
||||
Info("%s", separator)
|
||||
for _, line := range wrapText(*desc, innerWidth) {
|
||||
Info("%s", formatBoxLine(line, innerWidth))
|
||||
}
|
||||
}
|
||||
|
||||
Info("%s", bottomBorder)
|
||||
Info("")
|
||||
}
|
||||
|
||||
// formatBoxLine formats a line of text to fit within the box.
|
||||
func formatBoxLine(text string, innerWidth int) string {
|
||||
// Truncate if too long
|
||||
if len(text) > innerWidth {
|
||||
text = text[:innerWidth]
|
||||
}
|
||||
// Pad to fill the width
|
||||
padding := strings.Repeat(" ", innerWidth-len(text))
|
||||
return boxVertical + " " + text + padding + " " + boxVertical
|
||||
}
|
||||
|
||||
// wrapText wraps text to fit within maxWidth, respecting existing newlines.
|
||||
func wrapText(text string, maxWidth int) []string {
|
||||
var result []string
|
||||
|
||||
// Split by existing newlines first to respect user formatting
|
||||
for paragraph := range strings.SplitSeq(text, "\n") {
|
||||
if len(paragraph) == 0 {
|
||||
result = append(result, "")
|
||||
continue
|
||||
}
|
||||
|
||||
// Wrap each paragraph
|
||||
words := strings.Fields(paragraph)
|
||||
if len(words) == 0 {
|
||||
result = append(result, "")
|
||||
continue
|
||||
}
|
||||
|
||||
var currentLine string
|
||||
for _, word := range words {
|
||||
switch {
|
||||
case currentLine == "":
|
||||
currentLine = word
|
||||
case len(currentLine)+1+len(word) <= maxWidth:
|
||||
currentLine += " " + word
|
||||
default:
|
||||
result = append(result, currentLine)
|
||||
currentLine = word
|
||||
}
|
||||
}
|
||||
if currentLine != "" {
|
||||
result = append(result, currentLine)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
162
logger/logger_test.go
Normal file
162
logger/logger_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormatBoxLine(t *testing.T) {
|
||||
t.Run("formats short text with padding", func(t *testing.T) {
|
||||
// formatBoxLine adds: "│ " + text + padding + " │"
|
||||
// where padding = innerWidth - len(text)
|
||||
result := formatBoxLine("Hello", 20)
|
||||
// padding = 20 - 5 = 15 spaces
|
||||
// result = "│ " + "Hello" + 15 spaces + " │"
|
||||
assert.Equal(t, "│ Hello │", result)
|
||||
})
|
||||
|
||||
t.Run("formats empty string", func(t *testing.T) {
|
||||
result := formatBoxLine("", 10)
|
||||
// padding = 10 - 0 = 10 spaces
|
||||
// result = "│ " + "" + 10 spaces + " │"
|
||||
assert.Equal(t, "│ │", result)
|
||||
})
|
||||
|
||||
t.Run("truncates text longer than width", func(t *testing.T) {
|
||||
result := formatBoxLine("This is a very long text", 10)
|
||||
// text truncated to "This is a " (10 chars), padding = 0
|
||||
// result = "│ " + "This is a " + "" + " │"
|
||||
assert.Equal(t, "│ This is a │", result)
|
||||
})
|
||||
|
||||
t.Run("formats text exactly at width", func(t *testing.T) {
|
||||
result := formatBoxLine("1234567890", 10)
|
||||
// padding = 10 - 10 = 0 spaces
|
||||
assert.Equal(t, "│ 1234567890 │", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWrapText(t *testing.T) {
|
||||
t.Run("returns single line for short text", func(t *testing.T) {
|
||||
result := wrapText("Hello world", 20)
|
||||
assert.Equal(t, []string{"Hello world"}, result)
|
||||
})
|
||||
|
||||
t.Run("wraps long text", func(t *testing.T) {
|
||||
result := wrapText("This is a longer sentence that needs wrapping", 20)
|
||||
assert.Len(t, result, 3)
|
||||
assert.Equal(t, "This is a longer", result[0])
|
||||
assert.Equal(t, "sentence that needs", result[1])
|
||||
assert.Equal(t, "wrapping", result[2])
|
||||
})
|
||||
|
||||
t.Run("respects existing newlines", func(t *testing.T) {
|
||||
result := wrapText("Line one\nLine two\nLine three", 50)
|
||||
assert.Equal(t, []string{"Line one", "Line two", "Line three"}, result)
|
||||
})
|
||||
|
||||
t.Run("preserves blank lines", func(t *testing.T) {
|
||||
result := wrapText("First paragraph\n\nSecond paragraph", 50)
|
||||
assert.Equal(t, []string{"First paragraph", "", "Second paragraph"}, result)
|
||||
})
|
||||
|
||||
t.Run("wraps each paragraph separately", func(t *testing.T) {
|
||||
result := wrapText("Short\nThis is a longer line that will need to wrap", 20)
|
||||
assert.Len(t, result, 4)
|
||||
assert.Equal(t, "Short", result[0])
|
||||
assert.Equal(t, "This is a longer", result[1])
|
||||
assert.Equal(t, "line that will need", result[2])
|
||||
assert.Equal(t, "to wrap", result[3])
|
||||
})
|
||||
|
||||
t.Run("handles empty input", func(t *testing.T) {
|
||||
result := wrapText("", 20)
|
||||
assert.Equal(t, []string{""}, result)
|
||||
})
|
||||
|
||||
t.Run("handles single word longer than width", func(t *testing.T) {
|
||||
result := wrapText("Supercalifragilisticexpialidocious", 10)
|
||||
// Single word stays on one line even if longer than width
|
||||
assert.Equal(t, []string{"Supercalifragilisticexpialidocious"}, result)
|
||||
})
|
||||
|
||||
t.Run("handles multiple spaces between words", func(t *testing.T) {
|
||||
result := wrapText("Word1 Word2 Word3", 50)
|
||||
// strings.Fields collapses multiple spaces
|
||||
assert.Equal(t, []string{"Word1 Word2 Word3"}, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBoxDrawingConstants(t *testing.T) {
|
||||
t.Run("default box width is 60", func(t *testing.T) {
|
||||
assert.Equal(t, 60, boxDefaultWidth)
|
||||
})
|
||||
|
||||
t.Run("box characters are correct", func(t *testing.T) {
|
||||
assert.Equal(t, "┌", boxTopLeft)
|
||||
assert.Equal(t, "┐", boxTopRight)
|
||||
assert.Equal(t, "└", boxBottomLeft)
|
||||
assert.Equal(t, "┘", boxBottomRight)
|
||||
assert.Equal(t, "─", boxHorizontal)
|
||||
assert.Equal(t, "│", boxVertical)
|
||||
assert.Equal(t, "├", boxLeftT)
|
||||
assert.Equal(t, "┤", boxRightT)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetBoxWidth(t *testing.T) {
|
||||
t.Run("returns positive width", func(t *testing.T) {
|
||||
width := getBoxWidth()
|
||||
assert.Greater(t, width, 0)
|
||||
})
|
||||
|
||||
t.Run("returns at most default width", func(t *testing.T) {
|
||||
width := getBoxWidth()
|
||||
assert.LessOrEqual(t, width, boxDefaultWidth)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHighlight(t *testing.T) {
|
||||
t.Run("wraps text with highlight markers", func(t *testing.T) {
|
||||
result := Highlight("test")
|
||||
assert.Equal(t, highlightStart+"test"+highlightEnd, result)
|
||||
})
|
||||
|
||||
t.Run("H is alias for Highlight", func(t *testing.T) {
|
||||
assert.Equal(t, Highlight("test"), H("test"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestStripHighlightMarkers(t *testing.T) {
|
||||
t.Run("removes highlight markers", func(t *testing.T) {
|
||||
input := highlightStart + "highlighted" + highlightEnd + " normal"
|
||||
result := stripHighlightMarkers(input)
|
||||
assert.Equal(t, "highlighted normal", result)
|
||||
})
|
||||
|
||||
t.Run("handles text without markers", func(t *testing.T) {
|
||||
result := stripHighlightMarkers("plain text")
|
||||
assert.Equal(t, "plain text", result)
|
||||
})
|
||||
|
||||
t.Run("handles multiple highlighted sections", func(t *testing.T) {
|
||||
input := highlightStart + "one" + highlightEnd + " and " + highlightStart + "two" + highlightEnd
|
||||
result := stripHighlightMarkers(input)
|
||||
assert.Equal(t, "one and two", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessHighlights(t *testing.T) {
|
||||
t.Run("converts markers to ANSI codes", func(t *testing.T) {
|
||||
input := highlightStart + "text" + highlightEnd
|
||||
result := processHighlights(input, ansiBlueBold)
|
||||
assert.Equal(t, ansiHighlight+"text"+ansiReset+ansiBlueBold, result)
|
||||
})
|
||||
|
||||
t.Run("handles empty base color", func(t *testing.T) {
|
||||
input := highlightStart + "text" + highlightEnd
|
||||
result := processHighlights(input, "")
|
||||
assert.Equal(t, ansiHighlight+"text"+ansiReset, result)
|
||||
})
|
||||
}
|
||||
@@ -66,8 +66,7 @@ func getMachineIDDarwin() (string, error) {
|
||||
}
|
||||
|
||||
// Parse the IOPlatformUUID from the output
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
if strings.Contains(line, "IOPlatformUUID") {
|
||||
// Format: "IOPlatformUUID" = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||
parts := strings.Split(line, "=")
|
||||
@@ -115,8 +114,7 @@ func getMachineIDWindows() (string, error) {
|
||||
}
|
||||
|
||||
// Parse the MachineGuid from the output
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
if strings.Contains(line, "MachineGuid") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 {
|
||||
|
||||
37
main.go
37
main.go
@@ -84,11 +84,26 @@ func main() {
|
||||
}
|
||||
|
||||
logger.Info("Checking all installers...")
|
||||
instances := []installer.IInstaller{}
|
||||
|
||||
// First pass: validate all installers (skip category entries)
|
||||
type installItem struct {
|
||||
installer installer.IInstaller
|
||||
isCategory bool
|
||||
data *appconfig.InstallerData
|
||||
}
|
||||
items := []installItem{}
|
||||
hasValidationErrors := false
|
||||
|
||||
for _, i := range cfg.Install {
|
||||
installerInstance, err := installer.GetInstaller(cfg, &i)
|
||||
for idx := range cfg.Install {
|
||||
i := &cfg.Install[idx]
|
||||
|
||||
// Handle category entries specially - they don't need validation
|
||||
if i.IsCategory() {
|
||||
items = append(items, installItem{isCategory: true, data: i})
|
||||
continue
|
||||
}
|
||||
|
||||
installerInstance, err := installer.GetInstaller(cfg, i)
|
||||
if err != nil {
|
||||
logger.Error("%s", err)
|
||||
return
|
||||
@@ -100,10 +115,10 @@ func main() {
|
||||
if len(errors) > 0 {
|
||||
hasValidationErrors = true
|
||||
for _, e := range errors {
|
||||
logger.Error(e.Error())
|
||||
logger.Error("%s", e.Error())
|
||||
}
|
||||
} else {
|
||||
instances = append(instances, installerInstance)
|
||||
items = append(items, installItem{installer: installerInstance, data: i})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,8 +134,8 @@ func main() {
|
||||
interrupted := false
|
||||
|
||||
installSummary := summary.NewSummary()
|
||||
for _, i := range instances {
|
||||
// Check for interrupt before each installer
|
||||
for _, item := range items {
|
||||
// Check for interrupt before each item
|
||||
select {
|
||||
case <-sigChan:
|
||||
interrupted = true
|
||||
@@ -131,7 +146,13 @@ func main() {
|
||||
break
|
||||
}
|
||||
|
||||
result, err := installer.RunInstaller(cfg, i)
|
||||
// Handle category entries - just log the header
|
||||
if item.isCategory {
|
||||
logger.Category(*item.data.Category, item.data.Desc)
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := installer.RunInstaller(cfg, item.installer)
|
||||
if err != nil {
|
||||
logger.Error("%s", err)
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user