From d1c5a0f9a1fac739cd7580988873a4f02a417904 Mon Sep 17 00:00:00 2001 From: buty4649 Date: Mon, 30 Jun 2025 18:44:46 +0900 Subject: [PATCH 1/2] Add comprehensive CSV output support --- README.md | 2 + cmd/app.go | 4 +- cmd/event.go | 4 +- cmd/user.go | 2 +- utils/output.go | 97 ++++++++++++++++++++++++++++++++++++++++++++ utils/output_test.go | 69 +++++++++++++++++++++++++++++++ 6 files changed, 173 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 50a88e0..a520d79 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/app.go b/cmd/app.go index 22dad35..8a4ff23 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -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)") } diff --git a/cmd/event.go b/cmd/event.go index 250bebe..96ab9e9 100644 --- a/cmd/event.go +++ b/cmd/event.go @@ -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") @@ -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)") } diff --git a/cmd/user.go b/cmd/user.go index 1e8e82c..aa0395c 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -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") diff --git a/utils/output.go b/utils/output.go index 87240d5..9c661e5 100644 --- a/utils/output.go +++ b/utils/output.go @@ -1,9 +1,14 @@ package utils import ( + "encoding/csv" "encoding/json" + "fmt" "io" "os" + "reflect" + "strconv" + "time" "github.com/goccy/go-yaml" ) @@ -14,6 +19,7 @@ type OutputFormat string const ( OutputFormatYAML OutputFormat = "yaml" OutputFormatJSON OutputFormat = "json" + OutputFormatCSV OutputFormat = "csv" ) // PrintOutput は指定された形式でデータを出力します @@ -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()) + } +} diff --git a/utils/output_test.go b/utils/output_test.go index bba78f1..c13c5cd 100644 --- a/utils/output_test.go +++ b/utils/output_test.go @@ -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 { @@ -122,6 +189,8 @@ func TestPrintOutput(t *testing.T) { if tt.format == OutputFormatJSON { assert.JSONEq(t, tt.expected.(string), output) + } else if tt.format == OutputFormatCSV { + assert.Equal(t, tt.expected.(string), output) } else { expectedBytes, err := yaml.Marshal(tt.expected) assert.NoError(t, err) From cb8509a84b450b42d8c028b88e338f8e47896026 Mon Sep 17 00:00:00 2001 From: buty4649 Date: Mon, 30 Jun 2025 18:48:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Fix:=20use=20tagged=20switch=20for=20output?= =?UTF-8?q?=20format=20in=20tests=20(staticcheck=E5=AF=BE=E5=BF=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- utils/output_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/utils/output_test.go b/utils/output_test.go index c13c5cd..38b4efc 100644 --- a/utils/output_test.go +++ b/utils/output_test.go @@ -187,11 +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 if tt.format == OutputFormatCSV { + case OutputFormatCSV: assert.Equal(t, tt.expected.(string), output) - } else { + default: expectedBytes, err := yaml.Marshal(tt.expected) assert.NoError(t, err) assert.YAMLEq(t, string(expectedBytes), output)