Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,12 @@ All list commands support multiple output formats:

- `yaml` (default)
- `json`
- `csv`

Example:
```bash
onecli user list --output json
onecli user list --output csv
```

## Configuration
Expand Down
4 changes: 2 additions & 2 deletions cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,9 @@ func init() {
appCmd.AddCommand(appListCmd)
appCmd.AddCommand(appListUsersCmd)

appListCmd.Flags().StringVarP(&appOutput, "output", "o", "yaml", "Output format (yaml, json)")
appListCmd.Flags().StringVarP(&appOutput, "output", "o", "yaml", "Output format (yaml, json, csv)")
appListCmd.Flags().StringVar(&appQueryName, "name", "", "Filter apps by name")
appListCmd.Flags().BoolVar(&appDetail, "detail", false, "Include user details for each app")

appListUsersCmd.Flags().StringVarP(&appOutput, "output", "o", "yaml", "Output format (yaml, json)")
appListUsersCmd.Flags().StringVarP(&appOutput, "output", "o", "yaml", "Output format (yaml, json, csv)")
}
4 changes: 2 additions & 2 deletions cmd/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ func init() {
eventCmd.AddCommand(eventListCmd)
eventCmd.AddCommand(eventTypesCmd)

eventListCmd.Flags().StringVarP(&eventOutput, "output", "o", "yaml", "Output format (yaml, json)")
eventListCmd.Flags().StringVarP(&eventOutput, "output", "o", "yaml", "Output format (yaml, json, csv)")
eventListCmd.Flags().StringVar(&eventQueryClientID, "client-id", "", "Filter events by client ID")
eventListCmd.Flags().StringVar(&eventQueryCreatedAt, "created-at", "", "Filter events by created at")
eventListCmd.Flags().StringVar(&eventQueryDirectoryID, "directory-id", "", "Filter events by directory ID")
Expand All @@ -174,5 +174,5 @@ func init() {
// Make --type and --type-id mutually exclusive
eventListCmd.MarkFlagsMutuallyExclusive("type", "type-id")

eventTypesCmd.Flags().StringVarP(&eventOutput, "output", "o", "yaml", "Output format (yaml, json)")
eventTypesCmd.Flags().StringVarP(&eventOutput, "output", "o", "yaml", "Output format (yaml, json, csv)")
}
2 changes: 1 addition & 1 deletion cmd/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ func init() {
modifyCmd.AddCommand(modifyEmailCmd)
userCmd.AddCommand(addCmd)

listCmd.Flags().StringVarP(&output, "output", "o", "yaml", "Output format (yaml, json)")
listCmd.Flags().StringVarP(&output, "output", "o", "yaml", "Output format (yaml, json, csv)")
listCmd.Flags().StringVar(&userQueryEmail, "email", "", "Filter users by email")
listCmd.Flags().StringVar(&userQueryUsername, "username", "", "Filter users by username")
listCmd.Flags().StringVar(&userQueryFirstname, "firstname", "", "Filter users by first name")
Expand Down
97 changes: 97 additions & 0 deletions utils/output.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package utils

import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"os"
"reflect"
"strconv"
"time"

"github.com/goccy/go-yaml"
)
Expand All @@ -14,6 +19,7 @@ type OutputFormat string
const (
OutputFormatYAML OutputFormat = "yaml"
OutputFormatJSON OutputFormat = "json"
OutputFormatCSV OutputFormat = "csv"
)

// PrintOutput は指定された形式でデータを出力します
Expand All @@ -31,7 +37,98 @@ func PrintOutput(data any, format OutputFormat, writer io.Writer) error {
return encoder.Encode(data)
case OutputFormatYAML:
return yaml.NewEncoder(writer).Encode(data)
case OutputFormatCSV:
return encodeCSV(data, writer)
default:
return yaml.NewEncoder(writer).Encode(data)
}
}

// encodeCSV はデータをCSV形式でエンコードします
func encodeCSV(data any, writer io.Writer) error {
csvWriter := csv.NewWriter(writer)
defer csvWriter.Flush()

// データがスライスでない場合はエラー
val := reflect.ValueOf(data)
if val.Kind() != reflect.Slice {
return fmt.Errorf("CSV output requires slice data, got %v", val.Kind())
}

if val.Len() == 0 {
return nil
}

// 最初の要素からヘッダーを生成
firstElem := val.Index(0)
if firstElem.Kind() == reflect.Ptr {
firstElem = firstElem.Elem()
}

if firstElem.Kind() != reflect.Struct {
return fmt.Errorf("CSV output requires struct slice, got %v", firstElem.Kind())
}

// ヘッダーを生成
var headers []string
elemType := firstElem.Type()
for i := 0; i < elemType.NumField(); i++ {
field := elemType.Field(i)
headers = append(headers, field.Name)
}

// ヘッダーを書き込み
if err := csvWriter.Write(headers); err != nil {
return fmt.Errorf("error writing CSV headers: %v", err)
}

// データ行を書き込み
for i := 0; i < val.Len(); i++ {
elem := val.Index(i)
if elem.Kind() == reflect.Ptr {
elem = elem.Elem()
}

var row []string
for j := 0; j < elem.NumField(); j++ {
field := elem.Field(j)
row = append(row, formatFieldValue(field))
}

if err := csvWriter.Write(row); err != nil {
return fmt.Errorf("error writing CSV row %d: %v", i, err)
}
}

return nil
}

// formatFieldValue はフィールドの値を文字列に変換します
func formatFieldValue(field reflect.Value) string {
switch field.Kind() {
case reflect.String:
return field.String()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(field.Int(), 10)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return strconv.FormatUint(field.Uint(), 10)
case reflect.Float32, reflect.Float64:
return strconv.FormatFloat(field.Float(), 'f', -1, 64)
case reflect.Bool:
return strconv.FormatBool(field.Bool())
case reflect.Struct:
// time.Timeの場合はRFC3339形式で出力
if field.Type() == reflect.TypeOf(time.Time{}) {
t := field.Interface().(time.Time)
return t.Format(time.RFC3339)
}
return fmt.Sprintf("%v", field.Interface())
case reflect.Ptr:
if field.IsNil() {
return ""
}
return formatFieldValue(field.Elem())
default:
return fmt.Sprintf("%v", field.Interface())
}
}
74 changes: 72 additions & 2 deletions utils/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,73 @@ func TestPrintOutput(t *testing.T) {
expected: "",
wantErr: true,
},
{
name: "正常系: CSV形式で出力",
format: OutputFormatCSV,
data: []models.User{
{
ID: 1,
Username: "testuser1",
Email: "test1@example.com",
Firstname: "Test",
Lastname: "User1",
CreatedAt: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
ActivatedAt: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
State: 1,
Status: 1,
},
{
ID: 2,
Username: "testuser2",
Email: "test2@example.com",
Firstname: "Test",
Lastname: "User2",
CreatedAt: time.Date(2024, 4, 2, 12, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2024, 4, 2, 12, 0, 0, 0, time.UTC),
ActivatedAt: time.Date(2024, 4, 2, 12, 0, 0, 0, time.UTC),
State: 1,
Status: 1,
},
},
expected: "Firstname,Lastname,Username,Email,DistinguishedName,Samaccountname,UserPrincipalName,MemberOf,Phone,Password,PasswordConfirmation,PasswordAlgorithm,Salt,Title,Company,Department,ManagerADID,Comment,CreatedAt,UpdatedAt,ActivatedAt,LastLogin,PasswordChangedAt,LockedUntil,InvitationSentAt,State,Status,InvalidLoginAttempts,GroupID,RoleIDs,DirectoryID,TrustedIDPID,ManagerUserID,ExternalID,ID,CustomAttributes\nTest,User1,testuser1,test1@example.com,,,,[],,,,,,,,,0,,2024-04-01T12:00:00Z,2024-04-01T12:00:00Z,2024-04-01T12:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,1,1,0,0,[],0,0,0,,1,map[]\nTest,User2,testuser2,test2@example.com,,,,[],,,,,,,,,0,,2024-04-02T12:00:00Z,2024-04-02T12:00:00Z,2024-04-02T12:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,1,1,0,0,[],0,0,0,,2,map[]\n",
wantErr: false,
},
{
name: "異常系: CSV形式でスライス以外のデータ",
format: OutputFormatCSV,
data: "not a slice",
expected: "",
wantErr: true,
},
{
name: "異常系: CSV形式で空のスライス",
format: OutputFormatCSV,
data: []models.User{},
expected: "",
wantErr: false,
},
{
name: "正常系: CSV形式で改行を含む文字列",
format: OutputFormatCSV,
data: []models.User{
{
ID: 1,
Username: "testuser1",
Email: "test1@example.com",
Firstname: "Test",
Lastname: "User1",
Comment: "This is a comment\nwith multiple lines\nand special chars, like comma",
CreatedAt: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
ActivatedAt: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
State: 1,
Status: 1,
},
},
expected: "Firstname,Lastname,Username,Email,DistinguishedName,Samaccountname,UserPrincipalName,MemberOf,Phone,Password,PasswordConfirmation,PasswordAlgorithm,Salt,Title,Company,Department,ManagerADID,Comment,CreatedAt,UpdatedAt,ActivatedAt,LastLogin,PasswordChangedAt,LockedUntil,InvitationSentAt,State,Status,InvalidLoginAttempts,GroupID,RoleIDs,DirectoryID,TrustedIDPID,ManagerUserID,ExternalID,ID,CustomAttributes\nTest,User1,testuser1,test1@example.com,,,,[],,,,,,,,,0,\"This is a comment\nwith multiple lines\nand special chars, like comma\",2024-04-01T12:00:00Z,2024-04-01T12:00:00Z,2024-04-01T12:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,1,1,0,0,[],0,0,0,,1,map[]\n",
wantErr: false,
},
}

for _, tt := range tests {
Expand All @@ -120,9 +187,12 @@ func TestPrintOutput(t *testing.T) {
assert.NoError(t, err)
output := buf.String()

if tt.format == OutputFormatJSON {
switch tt.format {
case OutputFormatJSON:
assert.JSONEq(t, tt.expected.(string), output)
} else {
case OutputFormatCSV:
assert.Equal(t, tt.expected.(string), output)
default:
expectedBytes, err := yaml.Marshal(tt.expected)
assert.NoError(t, err)
assert.YAMLEq(t, string(expectedBytes), output)
Expand Down