rbcsv 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/DEVELOPMENT.md +146 -52
- data/README.md +66 -5
- data/ext/rbcsv/src/error.rs +14 -0
- data/ext/rbcsv/src/lib.rs +2 -1
- data/ext/rbcsv/src/parser.rs +129 -10
- data/ext/rbcsv/src/ruby_api.rs +15 -2
- data/lib/rbcsv/version.rb +1 -1
- data/test_write_functionality.rb +225 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6081f5083fa0f62ff08812aed7b6ff8fe47fbbc2a2170fc3d5ff3a4c6ef21661
|
|
4
|
+
data.tar.gz: a73748f6fefbf4a8fced9bf15f1f5db5ae3b4ef4fc11512e0f30a4ceb09b2e3f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f2bc15f8586443f6be96a47525688312938141f5d509fe1365b0c83c6e4776c2624678e4e2bdabb926b319875218d03e6e519e317acb42ab7749861197e72a8e
|
|
7
|
+
data.tar.gz: c0e37e6c24bae12583a538f79b9a0bba74d9b7ce06b16198c9b8764de1a2dd87cbcd1ca0681f580b65220d2a9c532c98fe7cc6fe04ad72cd6fb5a403ead1024a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.1.8] - 2025-01-28
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- CSV file writing functionality with `RbCsv.write(file_path, data)` method
|
|
7
|
+
- Comprehensive data validation (empty data check, field count consistency)
|
|
8
|
+
- Enhanced error handling for write operations (permission errors, invalid data)
|
|
9
|
+
- Full test coverage for write functionality with executable test script
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **CRITICAL**: Fixed special character handling in CSV parsing
|
|
13
|
+
- Removed problematic `escape_sanitize` function that interfered with standard CSV escaping
|
|
14
|
+
- Now properly preserves backslashes, newlines, tabs, and other special characters
|
|
15
|
+
- Ensures perfect round-trip fidelity for write/read operations
|
|
16
|
+
- Updated RSpec tests to reflect correct CSV parsing behavior
|
|
17
|
+
|
|
3
18
|
## [0.1.7] - 2025-01-28
|
|
4
19
|
|
|
5
20
|
### Changed
|
data/DEVELOPMENT.md
CHANGED
|
@@ -16,39 +16,6 @@
|
|
|
16
16
|
- **Linux**: x86_64 / aarch64
|
|
17
17
|
- **Windows**: x86_64(実験的サポート)
|
|
18
18
|
|
|
19
|
-
## プロジェクト構成
|
|
20
|
-
|
|
21
|
-
```
|
|
22
|
-
r_csv/
|
|
23
|
-
├── lib/
|
|
24
|
-
│ ├── rbcsv.rb # メインのRubyエントリーポイント
|
|
25
|
-
│ └── rbcsv/
|
|
26
|
-
│ ├── version.rb # バージョン定義
|
|
27
|
-
│ └── rbcsv.bundle # コンパイル済みネイティブ拡張(生成される)
|
|
28
|
-
├── ext/
|
|
29
|
-
│ └── rbcsv/
|
|
30
|
-
│ ├── src/
|
|
31
|
-
│ │ ├── lib.rs # Rust拡張のエントリーポイント、Magnus初期化
|
|
32
|
-
│ │ ├── parser.rs # CSV解析コア、CsvParseOptions定義
|
|
33
|
-
│ │ ├── ruby_api.rs # Ruby APIバインディング、オプション処理
|
|
34
|
-
│ │ └── error.rs # エラーハンドリング
|
|
35
|
-
│ ├── Cargo.toml # Rust依存関係(Magnus 0.8.1使用)
|
|
36
|
-
│ └── extconf.rb # Ruby拡張ビルド設定
|
|
37
|
-
├── spec/
|
|
38
|
-
│ ├── rbcsv_spec.rb # メインのRubyテスト
|
|
39
|
-
│ └── spec_helper.rb # テスト設定
|
|
40
|
-
├── docs/ # ドキュメント
|
|
41
|
-
├── target/ # Rustビルド出力(git無視)
|
|
42
|
-
├── tmp/ # Ruby拡張ビルド中間ファイル(git無視)
|
|
43
|
-
├── *.gem # ビルド済みgemファイル
|
|
44
|
-
├── rbcsv.gemspec # Gem仕様
|
|
45
|
-
├── Rakefile # ビルドタスク(rb_sys使用)
|
|
46
|
-
├── Gemfile # Ruby依存関係
|
|
47
|
-
├── Cargo.toml # ワークスペース設定
|
|
48
|
-
├── CHANGELOG.md # 変更履歴
|
|
49
|
-
├── README.md # 使用法ガイド
|
|
50
|
-
└── DEVELOPMENT.md # このファイル
|
|
51
|
-
```
|
|
52
19
|
|
|
53
20
|
## 開発環境のセットアップ
|
|
54
21
|
|
|
@@ -77,16 +44,100 @@ bundle exec rake compile
|
|
|
77
44
|
|
|
78
45
|
### 4. 動作確認
|
|
79
46
|
|
|
47
|
+
#### CSV パース機能
|
|
48
|
+
|
|
80
49
|
```bash
|
|
81
|
-
#
|
|
82
|
-
ruby -I lib -e "require 'rbcsv'; p RbCsv.parse('a,b\n1,2'
|
|
50
|
+
# 基本的なパース
|
|
51
|
+
ruby -I lib -e "require 'rbcsv'; p RbCsv.parse('a,b\n1,2')"
|
|
83
52
|
# 期待される出力: [["a", "b"], ["1", "2"]]
|
|
84
53
|
|
|
85
|
-
#
|
|
86
|
-
ruby -I lib -e "require 'rbcsv'; p RbCsv.parse(' a , b \n 1 , 2 '
|
|
54
|
+
# trim機能付きパース
|
|
55
|
+
ruby -I lib -e "require 'rbcsv'; p RbCsv.parse!(' a , b \n 1 , 2 ')"
|
|
87
56
|
# 期待される出力: [["a", "b"], ["1", "2"]]
|
|
88
57
|
```
|
|
89
58
|
|
|
59
|
+
#### CSV ファイル読み込み機能
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# CSVファイル読み込み
|
|
63
|
+
ruby -I lib -e "require 'rbcsv'; p RbCsv.read('spec/fixtures/test.csv')"
|
|
64
|
+
# 期待される出力: [["name", "age", "city"], ["Alice", "25", "Tokyo"], ...]
|
|
65
|
+
|
|
66
|
+
# trim機能付きファイル読み込み
|
|
67
|
+
ruby -I lib -e "require 'rbcsv'; p RbCsv.read!('spec/fixtures/test_with_spaces.csv')"
|
|
68
|
+
# 期待される出力: [["name", "age", "city"], ["Alice", "25", "Tokyo"], ...]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
#### CSV ファイル書き込み機能
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# 基本的なファイル書き込み
|
|
75
|
+
ruby -I lib -e "
|
|
76
|
+
require 'rbcsv'
|
|
77
|
+
data = [['name', 'age', 'city'], ['Alice', '25', 'Tokyo'], ['Bob', '30', 'Osaka']]
|
|
78
|
+
RbCsv.write('/tmp/test_output.csv', data)
|
|
79
|
+
puts 'File written successfully!'
|
|
80
|
+
puts File.read('/tmp/test_output.csv')
|
|
81
|
+
"
|
|
82
|
+
# 期待される出力:
|
|
83
|
+
# File written successfully!
|
|
84
|
+
# name,age,city
|
|
85
|
+
# Alice,25,Tokyo
|
|
86
|
+
# Bob,30,Osaka
|
|
87
|
+
|
|
88
|
+
# 書き込み→読み込みの往復テスト
|
|
89
|
+
ruby -I lib -e "
|
|
90
|
+
require 'rbcsv'
|
|
91
|
+
data = [['product', 'price'], ['Apple', '100'], ['Orange', '80']]
|
|
92
|
+
RbCsv.write('/tmp/roundtrip.csv', data)
|
|
93
|
+
result = RbCsv.read('/tmp/roundtrip.csv')
|
|
94
|
+
puts 'Original data:'
|
|
95
|
+
p data
|
|
96
|
+
puts 'Read back data:'
|
|
97
|
+
p result
|
|
98
|
+
puts 'Match: #{data == result}'
|
|
99
|
+
"
|
|
100
|
+
# 期待される出力: Match: true
|
|
101
|
+
|
|
102
|
+
# エラーハンドリングテスト(空データ)
|
|
103
|
+
ruby -I lib -e "
|
|
104
|
+
require 'rbcsv'
|
|
105
|
+
begin
|
|
106
|
+
RbCsv.write('/tmp/empty.csv', [])
|
|
107
|
+
rescue => e
|
|
108
|
+
puts 'Error caught: #{e.message}'
|
|
109
|
+
end
|
|
110
|
+
"
|
|
111
|
+
# 期待される出力: Error caught: Invalid Data Error: CSV data is empty
|
|
112
|
+
|
|
113
|
+
# エラーハンドリングテスト(フィールド数不一致)
|
|
114
|
+
ruby -I lib -e "
|
|
115
|
+
require 'rbcsv'
|
|
116
|
+
begin
|
|
117
|
+
data = [['name', 'age'], ['Alice', '25', 'Tokyo']]
|
|
118
|
+
RbCsv.write('/tmp/mismatch.csv', data)
|
|
119
|
+
rescue => e
|
|
120
|
+
puts 'Error caught: #{e.message}'
|
|
121
|
+
end
|
|
122
|
+
"
|
|
123
|
+
# 期待される出力: Error caught: Invalid Data Error: Field count mismatch at line 2: expected 2 fields, got 3 fields
|
|
124
|
+
|
|
125
|
+
# ファイル上書きテスト
|
|
126
|
+
ruby -I lib -e "
|
|
127
|
+
require 'rbcsv'
|
|
128
|
+
# 最初のデータを書き込み
|
|
129
|
+
RbCsv.write('/tmp/overwrite_test.csv', [['old'], ['data']])
|
|
130
|
+
puts 'First write:'
|
|
131
|
+
puts File.read('/tmp/overwrite_test.csv')
|
|
132
|
+
|
|
133
|
+
# 新しいデータで上書き
|
|
134
|
+
RbCsv.write('/tmp/overwrite_test.csv', [['new', 'data'], ['updated', 'content']])
|
|
135
|
+
puts 'After overwrite:'
|
|
136
|
+
puts File.read('/tmp/overwrite_test.csv')
|
|
137
|
+
"
|
|
138
|
+
# 期待される出力: 最初にold,dataが出力され、その後new,data形式に変わる
|
|
139
|
+
```
|
|
140
|
+
|
|
90
141
|
## ビルドプロセス
|
|
91
142
|
|
|
92
143
|
### 自動ビルド(推奨)
|
|
@@ -205,28 +256,71 @@ cd ../..
|
|
|
205
256
|
|
|
206
257
|
## API設計
|
|
207
258
|
|
|
208
|
-
### 現在のAPI(v0.1.
|
|
259
|
+
### 現在のAPI(v0.1.7+)
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
# 関数ベースAPI(`!`サフィックスでtrim機能分離)
|
|
263
|
+
RbCsv.parse(csv_string) # 通常のパース
|
|
264
|
+
RbCsv.parse!(csv_string) # trim機能付きパース
|
|
265
|
+
RbCsv.read(file_path) # 通常のファイル読み込み
|
|
266
|
+
RbCsv.read!(file_path) # trim機能付きファイル読み込み
|
|
267
|
+
RbCsv.write(file_path, data) # CSVファイル書き込み
|
|
268
|
+
|
|
269
|
+
# データ形式
|
|
270
|
+
data = [
|
|
271
|
+
["header1", "header2", "header3"], # ヘッダー行
|
|
272
|
+
["value1", "value2", "value3"], # データ行
|
|
273
|
+
# ...
|
|
274
|
+
]
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### API進化の履歴
|
|
209
278
|
|
|
279
|
+
#### v0.1.6以前(オプションベース)
|
|
210
280
|
```ruby
|
|
211
|
-
|
|
212
|
-
RbCsv.
|
|
213
|
-
|
|
281
|
+
RbCsv.parse(csv_string, {trim: true})
|
|
282
|
+
RbCsv.read(file_path, {trim: false})
|
|
283
|
+
```
|
|
214
284
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
#
|
|
219
|
-
# headers: true/false
|
|
220
|
-
# delimiter: ','
|
|
221
|
-
}
|
|
285
|
+
#### v0.1.7+(関数ベース)
|
|
286
|
+
```ruby
|
|
287
|
+
RbCsv.parse!(csv_string) # trim版
|
|
288
|
+
RbCsv.write(file_path, data) # 新機能
|
|
222
289
|
```
|
|
223
290
|
|
|
224
291
|
### 実装アーキテクチャ
|
|
225
292
|
|
|
226
|
-
1. **parser.rs**:
|
|
227
|
-
2. **ruby_api.rs**: Ruby
|
|
293
|
+
1. **parser.rs**: CSV解析・書き込みコア機能、エラーハンドリング
|
|
294
|
+
2. **ruby_api.rs**: Ruby API関数、Magnus バインディング
|
|
228
295
|
3. **lib.rs**: Magnus初期化と関数登録
|
|
229
|
-
4. **error.rs**:
|
|
296
|
+
4. **error.rs**: 包括的なエラーハンドリングとRuby例外変換
|
|
297
|
+
|
|
298
|
+
### 開発時の重要な注意点
|
|
299
|
+
|
|
300
|
+
#### Ruby拡張ライブラリの特殊性
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
# ❌ 避けるべき: cargo buildは直接使用しない
|
|
304
|
+
# cargo build は通常のRustライブラリ用で、Ruby拡張では適切にリンクされない
|
|
305
|
+
|
|
306
|
+
# ✅ 推奨される開発フロー:
|
|
307
|
+
cd ext/rbcsv
|
|
308
|
+
cargo check # 構文チェック(リンクなし)
|
|
309
|
+
cargo test # Rust単体テスト
|
|
310
|
+
cd ../..
|
|
311
|
+
bundle exec rake compile # Ruby拡張ビルド
|
|
312
|
+
bundle exec rspec # Ruby統合テスト
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
#### ビルドコマンドの使い分け
|
|
316
|
+
|
|
317
|
+
| コマンド | 用途 | 場所 | 備考 |
|
|
318
|
+
|---------|------|------|------|
|
|
319
|
+
| `cargo check` | 構文・型チェック | ext/rbcsv | 高速、リンクなし |
|
|
320
|
+
| `cargo test` | Rust単体テスト | ext/rbcsv | Rubyシンボル不要 |
|
|
321
|
+
| `cargo build` | **使用不可** | - | リンクエラーが発生 |
|
|
322
|
+
| `bundle exec rake compile` | Ruby拡張ビルド | プロジェクトルート | 本番用ビルド |
|
|
323
|
+
| `bundle exec rspec` | 統合テスト | プロジェクトルート | 完全な機能テスト |
|
|
230
324
|
|
|
231
325
|
## リリース手順
|
|
232
326
|
|
|
@@ -488,4 +582,4 @@ cd ../..
|
|
|
488
582
|
- [rb_sys Documentation](https://docs.rs/rb_sys/)
|
|
489
583
|
- [Ruby Extension Guide](https://docs.ruby-lang.org/en/master/extension_rdoc.html)
|
|
490
584
|
- [Cargo Book](https://doc.rust-lang.org/cargo/)
|
|
491
|
-
- [RubyGems Guides](https://guides.rubygems.org/)
|
|
585
|
+
- [RubyGems Guides](https://guides.rubygems.org/)
|
data/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# RbCsv
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/r_csv`. To experiment with that code, run `bin/console` for an interactive prompt.
|
|
3
|
+
A fast CSV processing library for Ruby, built with Rust for high performance. RbCsv provides simple, efficient methods for parsing CSV strings, reading CSV files, and writing CSV data to files.
|
|
6
4
|
|
|
7
5
|
## Installation
|
|
8
6
|
|
|
@@ -22,7 +20,70 @@ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
|
22
20
|
|
|
23
21
|
## Usage
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
### Parsing CSV strings
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require 'rbcsv'
|
|
27
|
+
|
|
28
|
+
# Parse CSV string
|
|
29
|
+
csv_data = "name,age,city\nAlice,25,Tokyo\nBob,30,Osaka"
|
|
30
|
+
result = RbCsv.parse(csv_data)
|
|
31
|
+
# => [["name", "age", "city"], ["Alice", "25", "Tokyo"], ["Bob", "30", "Osaka"]]
|
|
32
|
+
|
|
33
|
+
# Parse CSV with automatic whitespace trimming
|
|
34
|
+
csv_with_spaces = " name , age , city \n Alice , 25 , Tokyo "
|
|
35
|
+
result = RbCsv.parse!(csv_with_spaces)
|
|
36
|
+
# => [["name", "age", "city"], ["Alice", "25", "Tokyo"]]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Reading CSV files
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# Read CSV file
|
|
43
|
+
result = RbCsv.read("data.csv")
|
|
44
|
+
# => [["name", "age", "city"], ["Alice", "25", "Tokyo"], ["Bob", "30", "Osaka"]]
|
|
45
|
+
|
|
46
|
+
# Read CSV file with automatic whitespace trimming
|
|
47
|
+
result = RbCsv.read!("data_with_spaces.csv")
|
|
48
|
+
# => [["name", "age", "city"], ["Alice", "25", "Tokyo"], ["Bob", "30", "Osaka"]]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Writing CSV files
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# Prepare data
|
|
55
|
+
data = [
|
|
56
|
+
["name", "age", "city"],
|
|
57
|
+
["Alice", "25", "Tokyo"],
|
|
58
|
+
["Bob", "30", "Osaka"]
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
# Write CSV data to file
|
|
62
|
+
RbCsv.write("output.csv", data)
|
|
63
|
+
# Creates a file with:
|
|
64
|
+
# name,age,city
|
|
65
|
+
# Alice,25,Tokyo
|
|
66
|
+
# Bob,30,Osaka
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Error Handling
|
|
70
|
+
|
|
71
|
+
RbCsv provides detailed error messages for various scenarios:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# Empty data
|
|
75
|
+
RbCsv.write("output.csv", [])
|
|
76
|
+
# => RuntimeError: Invalid Data Error: CSV data is empty
|
|
77
|
+
|
|
78
|
+
# Inconsistent field count
|
|
79
|
+
data = [["name", "age"], ["Alice", "25", "Tokyo"]] # 3 fields in second row
|
|
80
|
+
RbCsv.write("output.csv", data)
|
|
81
|
+
# => RuntimeError: Invalid Data Error: Field count mismatch at line 2: expected 2 fields, got 3 fields
|
|
82
|
+
|
|
83
|
+
# File not found
|
|
84
|
+
RbCsv.read("nonexistent.csv")
|
|
85
|
+
# => RuntimeError: IO Error: File not found: nonexistent.csv
|
|
86
|
+
```
|
|
26
87
|
|
|
27
88
|
## Development
|
|
28
89
|
|
data/ext/rbcsv/src/error.rs
CHANGED
|
@@ -19,6 +19,10 @@ pub enum ErrorKind {
|
|
|
19
19
|
FieldCountMismatch,
|
|
20
20
|
// 空のCSVデータ
|
|
21
21
|
EmptyData,
|
|
22
|
+
// 書き込み権限エラー
|
|
23
|
+
WritePermission,
|
|
24
|
+
// 無効なデータエラー
|
|
25
|
+
InvalidData,
|
|
22
26
|
// その他のエラー
|
|
23
27
|
#[allow(dead_code)]
|
|
24
28
|
Other,
|
|
@@ -32,6 +36,8 @@ impl fmt::Display for CsvError {
|
|
|
32
36
|
ErrorKind::Encoding => write!(f, "Encoding Error: {}", self.message),
|
|
33
37
|
ErrorKind::FieldCountMismatch => write!(f, "Field Count Mismatch: {}", self.message),
|
|
34
38
|
ErrorKind::EmptyData => write!(f, "Empty Data: {}", self.message),
|
|
39
|
+
ErrorKind::WritePermission => write!(f, "Write Permission Error: {}", self.message),
|
|
40
|
+
ErrorKind::InvalidData => write!(f, "Invalid Data Error: {}", self.message),
|
|
35
41
|
ErrorKind::Other => write!(f, "Error: {}", self.message),
|
|
36
42
|
}
|
|
37
43
|
}
|
|
@@ -70,6 +76,14 @@ impl CsvError {
|
|
|
70
76
|
pub fn empty_data() -> Self {
|
|
71
77
|
Self::new(ErrorKind::EmptyData, "CSV data is empty")
|
|
72
78
|
}
|
|
79
|
+
|
|
80
|
+
pub fn write_permission(message: impl Into<String>) -> Self {
|
|
81
|
+
Self::new(ErrorKind::WritePermission, message)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
pub fn invalid_data(message: impl Into<String>) -> Self {
|
|
85
|
+
Self::new(ErrorKind::InvalidData, message)
|
|
86
|
+
}
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
// csv crateのエラーからの変換
|
data/ext/rbcsv/src/lib.rs
CHANGED
|
@@ -3,7 +3,7 @@ mod parser;
|
|
|
3
3
|
mod ruby_api;
|
|
4
4
|
|
|
5
5
|
use magnus::{Object, Ruby};
|
|
6
|
-
use ruby_api::{parse, parse_trim, read, read_trim};
|
|
6
|
+
use ruby_api::{parse, parse_trim, read, read_trim, write};
|
|
7
7
|
|
|
8
8
|
#[magnus::init]
|
|
9
9
|
fn init(ruby: &Ruby) -> Result<(), magnus::Error> {
|
|
@@ -13,6 +13,7 @@ fn init(ruby: &Ruby) -> Result<(), magnus::Error> {
|
|
|
13
13
|
module.define_singleton_method("parse!", magnus::function!(parse_trim, 1))?;
|
|
14
14
|
module.define_singleton_method("read", magnus::function!(read, 1))?;
|
|
15
15
|
module.define_singleton_method("read!", magnus::function!(read_trim, 1))?;
|
|
16
|
+
module.define_singleton_method("write", magnus::function!(write, 2))?;
|
|
16
17
|
|
|
17
18
|
Ok(())
|
|
18
19
|
}
|
data/ext/rbcsv/src/parser.rs
CHANGED
|
@@ -4,11 +4,9 @@ use std::path::Path;
|
|
|
4
4
|
|
|
5
5
|
/// CSV解析のオプション設定
|
|
6
6
|
#[derive(Debug, Clone)]
|
|
7
|
+
#[allow(dead_code)]
|
|
7
8
|
pub struct CsvParseOptions {
|
|
8
9
|
pub trim: bool,
|
|
9
|
-
// 将来的な拡張用
|
|
10
|
-
// pub headers: bool,
|
|
11
|
-
// pub delimiter: char,
|
|
12
10
|
}
|
|
13
11
|
|
|
14
12
|
impl Default for CsvParseOptions {
|
|
@@ -35,13 +33,11 @@ pub fn parse_csv_core(input: &str, trim_config: csv::Trim) -> Result<Vec<Vec<Str
|
|
|
35
33
|
return Err(CsvError::empty_data());
|
|
36
34
|
}
|
|
37
35
|
|
|
38
|
-
//
|
|
39
|
-
let processed = escape_sanitize(input);
|
|
40
|
-
|
|
36
|
+
// CSV crate に任せて適切なパースを行う(escape_sanitize は削除)
|
|
41
37
|
let mut reader = csv::ReaderBuilder::new()
|
|
42
38
|
.has_headers(false) // ヘッダーを無効にして、すべての行を読み込む
|
|
43
39
|
.trim(trim_config)
|
|
44
|
-
.from_reader(
|
|
40
|
+
.from_reader(input.as_bytes());
|
|
45
41
|
|
|
46
42
|
let mut records = Vec::new();
|
|
47
43
|
|
|
@@ -77,13 +73,13 @@ pub fn parse_csv_core(input: &str, trim_config: csv::Trim) -> Result<Vec<Vec<Str
|
|
|
77
73
|
}
|
|
78
74
|
|
|
79
75
|
/// オプション設定を使ったCSV解析(文字列用)
|
|
80
|
-
pub fn
|
|
76
|
+
pub fn _parse_csv_with_options(input: &str, options: &CsvParseOptions) -> Result<Vec<Vec<String>>, CsvError> {
|
|
81
77
|
let trim_config = if options.trim { csv::Trim::All } else { csv::Trim::None };
|
|
82
78
|
parse_csv_core(input, trim_config)
|
|
83
79
|
}
|
|
84
80
|
|
|
85
81
|
/// オプション設定を使ったCSV解析(ファイル用)
|
|
86
|
-
pub fn
|
|
82
|
+
pub fn _parse_csv_file_with_options(file_path: &str, options: &CsvParseOptions) -> Result<Vec<Vec<String>>, CsvError> {
|
|
87
83
|
let trim_config = if options.trim { csv::Trim::All } else { csv::Trim::None };
|
|
88
84
|
parse_csv_file(file_path, trim_config)
|
|
89
85
|
}
|
|
@@ -184,4 +180,127 @@ mod tests {
|
|
|
184
180
|
assert_eq!(records[1], vec!["Alice", "25", "Tokyo"]);
|
|
185
181
|
assert_eq!(records[2], vec!["Bob", "30", "Osaka"]);
|
|
186
182
|
}
|
|
187
|
-
|
|
183
|
+
|
|
184
|
+
#[test]
|
|
185
|
+
fn test_write_csv_file_basic() {
|
|
186
|
+
|
|
187
|
+
let temp_path = "/tmp/test_write_csv.csv";
|
|
188
|
+
let test_data = vec![
|
|
189
|
+
vec!["name".to_string(), "age".to_string(), "city".to_string()],
|
|
190
|
+
vec!["Alice".to_string(), "25".to_string(), "Tokyo".to_string()],
|
|
191
|
+
vec!["Bob".to_string(), "30".to_string(), "Osaka".to_string()],
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
// ファイルに書き込み
|
|
195
|
+
let result = write_csv_file(temp_path, &test_data);
|
|
196
|
+
assert!(result.is_ok(), "Write should succeed");
|
|
197
|
+
|
|
198
|
+
// 書き込んだファイルを読み込んで検証
|
|
199
|
+
let content = std::fs::read_to_string(temp_path).expect("Failed to read written file");
|
|
200
|
+
let expected = "name,age,city\nAlice,25,Tokyo\nBob,30,Osaka\n";
|
|
201
|
+
assert_eq!(content, expected);
|
|
202
|
+
|
|
203
|
+
// クリーンアップ
|
|
204
|
+
let _ = std::fs::remove_file(temp_path);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#[test]
|
|
208
|
+
fn test_write_csv_file_empty_data() {
|
|
209
|
+
let temp_path = "/tmp/test_write_empty.csv";
|
|
210
|
+
let empty_data: Vec<Vec<String>> = vec![];
|
|
211
|
+
|
|
212
|
+
let result = write_csv_file(temp_path, &empty_data);
|
|
213
|
+
assert!(result.is_err());
|
|
214
|
+
if let Err(e) = result {
|
|
215
|
+
assert!(e.to_string().contains("CSV data is empty"));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#[test]
|
|
220
|
+
fn test_write_csv_file_field_count_mismatch() {
|
|
221
|
+
let temp_path = "/tmp/test_write_mismatch.csv";
|
|
222
|
+
let inconsistent_data = vec![
|
|
223
|
+
vec!["name".to_string(), "age".to_string()],
|
|
224
|
+
vec!["Alice".to_string(), "25".to_string(), "Tokyo".to_string()], // 3 fields instead of 2
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
let result = write_csv_file(temp_path, &inconsistent_data);
|
|
228
|
+
assert!(result.is_err());
|
|
229
|
+
if let Err(e) = result {
|
|
230
|
+
assert!(e.to_string().contains("Field count mismatch"));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#[test]
|
|
235
|
+
fn test_write_csv_file_permission_denied() {
|
|
236
|
+
// 書き込み権限のないパスをテスト(rootディレクトリ)
|
|
237
|
+
let result = write_csv_file("/root/test.csv", &vec![vec!["test".to_string()]]);
|
|
238
|
+
assert!(result.is_err());
|
|
239
|
+
if let Err(e) = result {
|
|
240
|
+
// Permission deniedまたはParent directory does not existのいずれかになる
|
|
241
|
+
let error_msg = e.to_string();
|
|
242
|
+
assert!(error_msg.contains("Permission denied") || error_msg.contains("Parent directory does not exist"));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// CSVデータをファイルに書き込む
|
|
248
|
+
pub fn write_csv_file(file_path: &str, data: &[Vec<String>]) -> Result<(), CsvError> {
|
|
249
|
+
// データ検証:空配列チェック
|
|
250
|
+
if data.is_empty() {
|
|
251
|
+
return Err(CsvError::invalid_data("CSV data is empty"));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// データ検証:各行のフィールド数一貫性チェック
|
|
255
|
+
if data.len() > 1 {
|
|
256
|
+
let expected_len = data[0].len();
|
|
257
|
+
for (line_num, row) in data.iter().enumerate() {
|
|
258
|
+
if row.len() != expected_len {
|
|
259
|
+
let error_msg = format!(
|
|
260
|
+
"Field count mismatch at line {}: expected {} fields, got {} fields",
|
|
261
|
+
line_num + 1,
|
|
262
|
+
expected_len,
|
|
263
|
+
row.len()
|
|
264
|
+
);
|
|
265
|
+
return Err(CsvError::invalid_data(error_msg));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ファイルパス検証:親ディレクトリの存在確認
|
|
271
|
+
let path = Path::new(file_path);
|
|
272
|
+
if let Some(parent) = path.parent() {
|
|
273
|
+
if !parent.exists() {
|
|
274
|
+
return Err(CsvError::io(format!("Parent directory does not exist: {}", parent.display())));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// CSV Writer作成とデータ書き込み
|
|
279
|
+
let file = match fs::File::create(path) {
|
|
280
|
+
Ok(file) => file,
|
|
281
|
+
Err(e) => {
|
|
282
|
+
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
|
283
|
+
return Err(CsvError::write_permission(format!("Permission denied: {}", file_path)));
|
|
284
|
+
}
|
|
285
|
+
return Err(CsvError::io(format!("Failed to create file '{}': {}", file_path, e)));
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
let mut writer = csv::WriterBuilder::new()
|
|
290
|
+
.has_headers(false)
|
|
291
|
+
.from_writer(file);
|
|
292
|
+
|
|
293
|
+
// データ書き込み
|
|
294
|
+
for row in data {
|
|
295
|
+
if let Err(e) = writer.write_record(row) {
|
|
296
|
+
return Err(CsvError::from(e));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ファイルフラッシュ:データの確実な書き込み保証
|
|
301
|
+
if let Err(e) = writer.flush() {
|
|
302
|
+
return Err(CsvError::io(format!("Failed to flush data to file '{}': {}", file_path, e)));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
Ok(())
|
|
306
|
+
}
|
data/ext/rbcsv/src/ruby_api.rs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
use magnus::{Error as MagnusError, Ruby};
|
|
2
|
-
use crate::parser::{parse_csv_core, parse_csv_file};
|
|
2
|
+
use crate::parser::{parse_csv_core, parse_csv_file, write_csv_file};
|
|
3
3
|
|
|
4
4
|
/// CSV文字列をパースする(通常版)
|
|
5
5
|
///
|
|
@@ -53,10 +53,23 @@ pub fn read_trim(ruby: &Ruby, file_path: String) -> Result<Vec<Vec<String>>, Mag
|
|
|
53
53
|
.map_err(|e| MagnusError::new(ruby.exception_runtime_error(), e.to_string()))
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/// CSVファイルに書き込む
|
|
57
|
+
///
|
|
58
|
+
/// # Arguments
|
|
59
|
+
/// * `ruby` - Ruby VMの参照
|
|
60
|
+
/// * `file_path` - 書き込み先ファイルのパス
|
|
61
|
+
/// * `data` - 書き込むCSVデータ(2次元配列)
|
|
62
|
+
///
|
|
63
|
+
/// # Returns
|
|
64
|
+
/// * `Result<(), MagnusError>` - 成功時は空、失敗時はエラー
|
|
65
|
+
pub fn write(ruby: &Ruby, file_path: String, data: Vec<Vec<String>>) -> Result<(), MagnusError> {
|
|
66
|
+
write_csv_file(&file_path, &data)
|
|
67
|
+
.map_err(|e| MagnusError::new(ruby.exception_runtime_error(), e.to_string()))
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
|
|
57
71
|
#[cfg(test)]
|
|
58
72
|
mod tests {
|
|
59
|
-
use super::*;
|
|
60
73
|
|
|
61
74
|
#[test]
|
|
62
75
|
fn test_parse_basic() {
|
data/lib/rbcsv/version.rb
CHANGED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# RbCsv.write() 機能のテストスクリプト
|
|
5
|
+
#
|
|
6
|
+
# 実行方法:
|
|
7
|
+
# ruby test_write_functionality.rb
|
|
8
|
+
#
|
|
9
|
+
# 前提条件:
|
|
10
|
+
# - bundle exec rake compile でライブラリがビルド済みであること
|
|
11
|
+
|
|
12
|
+
require_relative 'lib/rbcsv'
|
|
13
|
+
require 'fileutils'
|
|
14
|
+
|
|
15
|
+
class RbCsvWriteTest
|
|
16
|
+
def initialize
|
|
17
|
+
@test_dir = '/tmp/rbcsv_write_tests'
|
|
18
|
+
@success_count = 0
|
|
19
|
+
@total_count = 0
|
|
20
|
+
setup_test_directory
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run_all_tests
|
|
24
|
+
puts "=" * 60
|
|
25
|
+
puts "RbCsv.write() 機能テスト開始"
|
|
26
|
+
puts "=" * 60
|
|
27
|
+
puts
|
|
28
|
+
|
|
29
|
+
test_basic_write
|
|
30
|
+
test_roundtrip_write_read
|
|
31
|
+
test_file_overwrite
|
|
32
|
+
test_empty_data_error
|
|
33
|
+
test_field_count_mismatch_error
|
|
34
|
+
test_single_row_write
|
|
35
|
+
test_unicode_content
|
|
36
|
+
test_special_characters
|
|
37
|
+
|
|
38
|
+
puts
|
|
39
|
+
puts "=" * 60
|
|
40
|
+
puts "テスト結果: #{@success_count}/#{@total_count} 成功"
|
|
41
|
+
puts "=" * 60
|
|
42
|
+
|
|
43
|
+
cleanup_test_directory
|
|
44
|
+
|
|
45
|
+
if @success_count == @total_count
|
|
46
|
+
puts "✅ すべてのテストが成功しました!"
|
|
47
|
+
exit 0
|
|
48
|
+
else
|
|
49
|
+
puts "❌ 一部のテストが失敗しました"
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def setup_test_directory
|
|
57
|
+
FileUtils.mkdir_p(@test_dir)
|
|
58
|
+
puts "テストディレクトリを作成: #{@test_dir}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def cleanup_test_directory
|
|
62
|
+
FileUtils.rm_rf(@test_dir)
|
|
63
|
+
puts "テストディレクトリを削除: #{@test_dir}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def run_test(name)
|
|
67
|
+
@total_count += 1
|
|
68
|
+
print "#{name}... "
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
yield
|
|
72
|
+
@success_count += 1
|
|
73
|
+
puts "✅ 成功"
|
|
74
|
+
rescue => e
|
|
75
|
+
puts "❌ 失敗: #{e.message}"
|
|
76
|
+
puts " #{e.backtrace.first}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def test_basic_write
|
|
81
|
+
run_test("基本的なCSV書き込み") do
|
|
82
|
+
file_path = File.join(@test_dir, 'basic.csv')
|
|
83
|
+
data = [
|
|
84
|
+
['name', 'age', 'city'],
|
|
85
|
+
['Alice', '25', 'Tokyo'],
|
|
86
|
+
['Bob', '30', 'Osaka']
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
RbCsv.write(file_path, data)
|
|
90
|
+
|
|
91
|
+
content = File.read(file_path)
|
|
92
|
+
expected = "name,age,city\nAlice,25,Tokyo\nBob,30,Osaka\n"
|
|
93
|
+
|
|
94
|
+
raise "書き込み内容が期待値と異なります" unless content == expected
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def test_roundtrip_write_read
|
|
99
|
+
run_test("書き込み→読み込みの往復テスト") do
|
|
100
|
+
file_path = File.join(@test_dir, 'roundtrip.csv')
|
|
101
|
+
original_data = [
|
|
102
|
+
['product', 'price', 'category'],
|
|
103
|
+
['Apple', '100', 'Fruit'],
|
|
104
|
+
['Carrot', '50', 'Vegetable']
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
RbCsv.write(file_path, original_data)
|
|
108
|
+
read_data = RbCsv.read(file_path)
|
|
109
|
+
|
|
110
|
+
raise "往復テストで元データと異なります" unless original_data == read_data
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def test_file_overwrite
|
|
115
|
+
run_test("ファイル上書きテスト") do
|
|
116
|
+
file_path = File.join(@test_dir, 'overwrite.csv')
|
|
117
|
+
|
|
118
|
+
# 最初のデータ
|
|
119
|
+
first_data = [['old'], ['data']]
|
|
120
|
+
RbCsv.write(file_path, first_data)
|
|
121
|
+
first_content = File.read(file_path)
|
|
122
|
+
|
|
123
|
+
# 上書き
|
|
124
|
+
second_data = [['new', 'header'], ['updated', 'content']]
|
|
125
|
+
RbCsv.write(file_path, second_data)
|
|
126
|
+
second_content = File.read(file_path)
|
|
127
|
+
|
|
128
|
+
expected_first = "old\ndata\n"
|
|
129
|
+
expected_second = "new,header\nupdated,content\n"
|
|
130
|
+
|
|
131
|
+
raise "最初の書き込み内容が不正" unless first_content == expected_first
|
|
132
|
+
raise "上書き後の内容が不正" unless second_content == expected_second
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def test_empty_data_error
|
|
137
|
+
run_test("空データエラーテスト") do
|
|
138
|
+
file_path = File.join(@test_dir, 'empty.csv')
|
|
139
|
+
|
|
140
|
+
error_raised = false
|
|
141
|
+
begin
|
|
142
|
+
RbCsv.write(file_path, [])
|
|
143
|
+
rescue RuntimeError => e
|
|
144
|
+
error_raised = true
|
|
145
|
+
raise "エラーメッセージが期待値と異なります" unless e.message.include?("CSV data is empty")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
raise "空データでエラーが発生しませんでした" unless error_raised
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def test_field_count_mismatch_error
|
|
153
|
+
run_test("フィールド数不一致エラーテスト") do
|
|
154
|
+
file_path = File.join(@test_dir, 'mismatch.csv')
|
|
155
|
+
inconsistent_data = [
|
|
156
|
+
['name', 'age'],
|
|
157
|
+
['Alice', '25', 'Tokyo'] # 3フィールド(期待は2フィールド)
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
error_raised = false
|
|
161
|
+
begin
|
|
162
|
+
RbCsv.write(file_path, inconsistent_data)
|
|
163
|
+
rescue RuntimeError => e
|
|
164
|
+
error_raised = true
|
|
165
|
+
unless e.message.include?("Field count mismatch") && e.message.include?("line 2")
|
|
166
|
+
raise "エラーメッセージが期待値と異なります: #{e.message}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
raise "フィールド数不一致でエラーが発生しませんでした" unless error_raised
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def test_single_row_write
|
|
175
|
+
run_test("単一行書き込みテスト") do
|
|
176
|
+
file_path = File.join(@test_dir, 'single.csv')
|
|
177
|
+
data = [['single', 'row', 'test']]
|
|
178
|
+
|
|
179
|
+
RbCsv.write(file_path, data)
|
|
180
|
+
content = File.read(file_path)
|
|
181
|
+
expected = "single,row,test\n"
|
|
182
|
+
|
|
183
|
+
raise "単一行の書き込み内容が不正" unless content == expected
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def test_unicode_content
|
|
188
|
+
run_test("Unicode文字テスト") do
|
|
189
|
+
file_path = File.join(@test_dir, 'unicode.csv')
|
|
190
|
+
data = [
|
|
191
|
+
['名前', '年齢', '都市'],
|
|
192
|
+
['田中太郎', '30', '東京'],
|
|
193
|
+
['山田花子', '25', '大阪'],
|
|
194
|
+
['🎉', '😀', '🌸']
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
RbCsv.write(file_path, data)
|
|
198
|
+
read_data = RbCsv.read(file_path)
|
|
199
|
+
|
|
200
|
+
raise "Unicode文字の往復テストが失敗" unless data == read_data
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def test_special_characters
|
|
205
|
+
run_test("特殊文字テスト") do
|
|
206
|
+
file_path = File.join(@test_dir, 'special.csv')
|
|
207
|
+
data = [
|
|
208
|
+
['field1', 'field2', 'field3'],
|
|
209
|
+
['comma,test', 'quote"test', 'newline\ntest'],
|
|
210
|
+
['tab\ttest', 'backslash\\test', 'normal']
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
RbCsv.write(file_path, data)
|
|
214
|
+
read_data = RbCsv.read(file_path)
|
|
215
|
+
|
|
216
|
+
raise "特殊文字の往復テストが失敗" unless data == read_data
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# テスト実行
|
|
222
|
+
if __FILE__ == $0
|
|
223
|
+
tester = RbCsvWriteTest.new
|
|
224
|
+
tester.run_all_tests
|
|
225
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rbcsv
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- fujitani sora
|
|
@@ -64,6 +64,7 @@ files:
|
|
|
64
64
|
- test.rb
|
|
65
65
|
- test_fixed.rb
|
|
66
66
|
- test_install.rb
|
|
67
|
+
- test_write_functionality.rb
|
|
67
68
|
homepage: https://github.com/fs0414/rbcsv
|
|
68
69
|
licenses:
|
|
69
70
|
- MIT
|