1 Star 0 Fork 0

DAIPING/cleanenv

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
文件
克隆/下载
cleanenv_test.go 19.75 KB
一键复制 编辑 原始数据 按行查看 历史
Ilya Kaznacheev 提交于 2019-12-16 18:02 . Release v1.2.0
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958
package cleanenv
import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"os"
"reflect"
"testing"
"time"
)
type testUpdater struct {
Data string `env:"DATA"`
err error
}
func (t *testUpdater) Update() error {
return t.err
}
func TestReadEnvVars(t *testing.T) {
durationFunc := func(s string) time.Duration {
d, err := time.ParseDuration(s)
if err != nil {
t.Fatal(err)
}
return d
}
timeFunc := func(s, l string) time.Time {
tm, err := time.Parse(l, s)
if err != nil {
t.Fatal(err)
}
return tm
}
ta := &testUpdater{
err: errors.New("test"),
}
type Combined struct {
Empty int
Default int `env:"TEST0" env-default:"1"`
Global int `env:"TEST1" env-default:"1"`
local int `env:"TEST2" env-default:"1"`
}
type AllTypes struct {
Integer int64 `env:"TEST_INTEGER"`
UnsInteger uint64 `env:"TEST_UNSINTEGER"`
Float float64 `env:"TEST_FLOAT"`
Boolean bool `env:"TEST_BOOLEAN"`
String string `env:"TEST_STRING"`
Duration time.Duration `env:"TEST_DURATION"`
Time time.Time `env:"TEST_TIME"`
ArrayInt []int `env:"TEST_ARRAYINT"`
ArrayString []string `env:"TEST_ARRAYSTRING"`
MapStringInt map[string]int `env:"TEST_MAPSTRINGINT"`
MapStringString map[string]string `env:"TEST_MAPSTRINGSTRING"`
}
type TimeTypes struct {
Time1 time.Time `env:"TEST_TIME1"`
Time2 time.Time `env:"TEST_TIME2" env-layout:"Mon Jan _2 15:04:05 2006"`
Time3 time.Time `env:"TEST_TIME3" env-layout:"Jan _2 15:04:05"`
Time4 time.Time `env:"TEST_TIME4" env-default:"2012-04-23T18:25:43.511Z"`
Time5 time.Time `env:"TEST_TIME5" env-default:"Mon Mar 10 11:11:11 2011" env-layout:"Mon Jan _2 15:04:05 2006"`
Time6 []time.Time `env:"TEST_TIME6" env-separator:"|"`
Time7 map[string]time.Time `env:"TEST_TIME7" env-separator:"|"`
}
tests := []struct {
name string
env map[string]string
cfg interface{}
want interface{}
wantErr bool
}{
{
name: "combined",
env: map[string]string{
"TEST1": "2",
"TEST2": "3",
},
cfg: &Combined{},
want: &Combined{
Empty: 0,
Default: 1,
Global: 2,
local: 0,
},
wantErr: false,
},
{
name: "all types",
env: map[string]string{
"TEST_INTEGER": "-5",
"TEST_UNSINTEGER": "5",
"TEST_FLOAT": "5.5",
"TEST_BOOLEAN": "true",
"TEST_STRING": "test",
"TEST_DURATION": "1h5m10s",
"TEST_TIME": "2012-04-23T18:25:43.511Z",
"TEST_ARRAYINT": "1,2,3",
"TEST_ARRAYSTRING": "a,b,c",
"TEST_MAPSTRINGINT": "a:1,b:2,c:3",
"TEST_MAPSTRINGSTRING": "a:x,b:y,c:z",
},
cfg: &AllTypes{},
want: &AllTypes{
Integer: -5,
UnsInteger: 5,
Float: 5.5,
Boolean: true,
String: "test",
Duration: durationFunc("1h5m10s"),
Time: timeFunc("2012-04-23T18:25:43.511Z", time.RFC3339),
ArrayInt: []int{1, 2, 3},
ArrayString: []string{"a", "b", "c"},
MapStringInt: map[string]int{
"a": 1,
"b": 2,
"c": 3,
},
MapStringString: map[string]string{
"a": "x",
"b": "y",
"c": "z",
},
},
wantErr: false,
},
{
name: "times",
env: map[string]string{
"TEST_TIME1": "2012-04-23T18:25:43.511Z",
"TEST_TIME2": "Mon Mar 10 11:11:11 2011",
"TEST_TIME3": "Dec 1 11:11:11",
"TEST_TIME6": "2012-04-23T18:25:43.511Z|2012-05-23T18:25:43.511Z",
"TEST_TIME7": "a:2012-04-23T18:25:43.511Z|b:2012-05-23T18:25:43.511Z",
},
cfg: &TimeTypes{},
want: &TimeTypes{
Time1: timeFunc("2012-04-23T18:25:43.511Z", time.RFC3339),
Time2: timeFunc("Mon Mar 10 11:11:11 2011", time.ANSIC),
Time3: timeFunc("Dec 1 11:11:11", time.Stamp),
Time4: timeFunc("2012-04-23T18:25:43.511Z", time.RFC3339),
Time5: timeFunc("Mon Mar 10 11:11:11 2011", time.ANSIC),
Time6: []time.Time{
timeFunc("2012-04-23T18:25:43.511Z", time.RFC3339),
timeFunc("2012-05-23T18:25:43.511Z", time.RFC3339),
},
Time7: map[string]time.Time{
"a": timeFunc("2012-04-23T18:25:43.511Z", time.RFC3339),
"b": timeFunc("2012-05-23T18:25:43.511Z", time.RFC3339),
},
},
wantErr: false,
},
{
name: "wrong types",
env: map[string]string{
"TEST_INTEGER": "a",
"TEST_UNSINTEGER": "b",
"TEST_FLOAT": "c",
"TEST_BOOLEAN": "xxx",
"TEST_STRING": "",
"TEST_DURATION": "-",
"TEST_ARRAYINT": "a,b,c",
"TEST_ARRAYSTRING": "1,2,3",
"TEST_MAPSTRINGINT": "a:x,b:y,c:z",
"TEST_MAPSTRINGSTRING": "a:1,b:2,c:3",
},
cfg: &AllTypes{},
want: &AllTypes{},
wantErr: true,
},
{
name: "wrong int",
env: map[string]string{
"TEST_INTEGER": "a",
},
cfg: &AllTypes{},
want: &AllTypes{},
wantErr: true,
},
{
name: "wrong uint",
env: map[string]string{
"TEST_UNSINTEGER": "b",
},
cfg: &AllTypes{},
want: &AllTypes{},
wantErr: true,
},
{
name: "wrong float",
env: map[string]string{
"TEST_FLOAT": "c",
},
cfg: &AllTypes{},
want: &AllTypes{},
wantErr: true,
},
{
name: "wrong boolean",
env: map[string]string{
"TEST_BOOLEAN": "xxx",
},
cfg: &AllTypes{},
want: &AllTypes{},
wantErr: true,
},
{
name: "wrong duration",
env: map[string]string{
"TEST_DURATION": "-",
},
cfg: &AllTypes{},
want: &AllTypes{},
wantErr: true,
},
{
name: "wrong array int",
env: map[string]string{
"TEST_ARRAYINT": "a,b,c",
},
cfg: &AllTypes{},
want: &AllTypes{},
wantErr: true,
},
{
name: "wrong map int",
env: map[string]string{
"TEST_MAPSTRINGINT": "a:x,b:y,c:z",
},
cfg: &AllTypes{},
want: &AllTypes{},
wantErr: true,
},
{
name: "wrong map type int",
env: map[string]string{
"TEST_MAPSTRINGINT": "-",
},
cfg: &AllTypes{},
want: &AllTypes{},
wantErr: true,
},
{
name: "wrong map type string",
env: map[string]string{
"TEST_MAPSTRINGSTRING": "-",
},
cfg: &AllTypes{},
want: &AllTypes{},
wantErr: true,
},
{
name: "wrong config type",
cfg: 42,
want: 42,
wantErr: true,
},
{
name: "updater error",
cfg: ta,
want: ta,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for env, val := range tt.env {
os.Setenv(env, val)
}
defer os.Clearenv()
if err := readEnvVars(tt.cfg, false); (err != nil) != tt.wantErr {
t.Errorf("wrong error behavior %v, wantErr %v", err, tt.wantErr)
}
if !reflect.DeepEqual(tt.cfg, tt.want) {
t.Errorf("wrong data %v, want %v", tt.cfg, tt.want)
}
})
}
}
func TestReadEnvVarsTime(t *testing.T) {
timeFunc := func(s, l string) time.Time {
tm, err := time.Parse(l, s)
if err != nil {
t.Fatal(err)
}
return tm
}
type Timed struct {
Time time.Time `env:"TEST_TIME" env-layout:"Mon Jan _2 15:04:05 2006"`
}
tests := []struct {
name string
env map[string]string
cfg interface{}
want interface{}
wantErr bool
}{
{
name: "time",
env: map[string]string{
"TEST_TIME": "Mon Mar 10 11:11:11 2011",
},
cfg: &Timed{},
want: &Timed{
Time: timeFunc("Mon Mar 10 11:11:11 2011", time.ANSIC),
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for env, val := range tt.env {
os.Setenv(env, val)
}
defer os.Clearenv()
if err := readEnvVars(tt.cfg, false); (err != nil) != tt.wantErr {
t.Errorf("wrong error behavior %v, wantErr %v", err, tt.wantErr)
}
if !reflect.DeepEqual(tt.cfg, tt.want) {
t.Errorf("wrong data %v, want %v", tt.cfg, tt.want)
}
})
}
}
type testConfigUpdateFunction struct {
One string
Two string
Three string
}
func (f *testConfigUpdateFunction) Update() error {
f.One = "upd1:" + f.One
f.Two = "upd2:" + f.Two
f.Three = "upd3:" + f.Three
return nil
}
type testConfigUpdateNoFunction struct {
One string
Two string
Three string
}
func TestReadUpdateFunctions(t *testing.T) {
tests := []struct {
name string
cfg interface{}
want interface{}
wantErr bool
}{
{
name: "update structure with function",
cfg: &testConfigUpdateFunction{
One: "test1",
Two: "test2",
Three: "test3",
},
want: &testConfigUpdateFunction{
One: "upd1:test1",
Two: "upd2:test2",
Three: "upd3:test3",
},
wantErr: false,
},
{
name: "no update",
cfg: &testConfigUpdateNoFunction{
One: "test1",
Two: "test2",
Three: "test3",
},
want: &testConfigUpdateNoFunction{
One: "test1",
Two: "test2",
Three: "test3",
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := readEnvVars(tt.cfg, false); (err != nil) != tt.wantErr {
t.Errorf("wrong error behavior %v, wantErr %v", err, tt.wantErr)
}
if !reflect.DeepEqual(tt.cfg, tt.want) {
t.Errorf("wrong data %v, want %v", tt.cfg, tt.want)
}
})
}
}
func TestParseFile(t *testing.T) {
type configObject struct {
One int `yaml:"one" json:"one" toml:"one"`
Two int `yaml:"two" json:"two" toml:"two"`
}
type config struct {
Number int64 `yaml:"number" json:"number" toml:"number"`
Float float64 `yaml:"float" json:"float" toml:"float"`
String string `yaml:"string" json:"string" toml:"string"`
Boolean bool `yaml:"boolean" json:"boolean" toml:"boolean"`
Object configObject `yaml:"object" json:"object" toml:"object"`
Array []int `yaml:"array" json:"array" toml:"array"`
}
wantConfig := config{
Number: 1,
Float: 2.3,
String: "test",
Boolean: true,
Object: configObject{1, 2},
Array: []int{1, 2, 3},
}
tests := []struct {
name string
file string
ext string
want *config
wantErr bool
}{
{
name: "yaml",
file: `
number: 1
float: 2.3
string: test
boolean: yes
object:
one: 1
two: 2
array: [1, 2, 3]`,
ext: "yaml",
want: &wantConfig,
wantErr: false,
},
{
name: "json",
file: `{
"number": 1,
"float": 2.3,
"string": "test",
"boolean": true,
"object": {
"one": 1,
"two": 2
},
"array": [1, 2, 3]
}`,
ext: "json",
want: &wantConfig,
wantErr: false,
},
{
name: "toml",
file: `
number = 1
float = 2.3
string = "test"
boolean = true
array = [1, 2, 3]
[object]
one = 1
two = 2`,
ext: "toml",
want: &wantConfig,
wantErr: false,
},
{
name: "unknown",
file: "-",
ext: "",
want: nil,
wantErr: true,
},
{
name: "parsing error",
file: "-",
ext: "json",
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpFile, err := ioutil.TempFile(os.TempDir(), fmt.Sprintf("*.%s", tt.ext))
if err != nil {
t.Fatal("cannot create temporary file:", err)
}
defer os.Remove(tmpFile.Name())
text := []byte(tt.file)
if _, err = tmpFile.Write(text); err != nil {
t.Fatal("failed to write to temporary file:", err)
}
var cfg config
if err = parseFile(tmpFile.Name(), &cfg); (err != nil) != tt.wantErr {
t.Errorf("wrong error behavior %v, wantErr %v", err, tt.wantErr)
}
if err == nil && !reflect.DeepEqual(&cfg, tt.want) {
t.Errorf("wrong data %v, want %v", &cfg, tt.want)
}
})
}
t.Run("invalid path", func(t *testing.T) {
err := parseFile("invalid file path", nil)
if err == nil {
t.Error("expected error for invalid file path")
}
})
}
func TestParseFileEnv(t *testing.T) {
type dummy struct{}
tests := []struct {
name string
rawFile string
has map[string]string
want map[string]string
wantErr bool
}{
{
name: "simple file",
has: map[string]string{
"TEST1": "aaa",
"TEST2": "bbb",
"TEST3": "ccc",
},
want: map[string]string{
"TEST1": "aaa",
"TEST2": "bbb",
"TEST3": "ccc",
},
wantErr: false,
},
{
name: "empty file",
has: map[string]string{},
want: map[string]string{},
wantErr: false,
},
{
name: "error",
rawFile: "-",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpFile, err := ioutil.TempFile(os.TempDir(), "*.env")
if err != nil {
t.Fatal("cannot create temporary file:", err)
}
defer os.Remove(tmpFile.Name())
var file string
if tt.rawFile == "" {
for key, val := range tt.has {
file += fmt.Sprintf("%s=%s\n", key, val)
}
} else {
file = tt.rawFile
}
text := []byte(file)
if _, err = tmpFile.Write(text); err != nil {
t.Fatal("failed to write to temporary file:", err)
}
var cfg dummy
if err = parseFile(tmpFile.Name(), &cfg); (err != nil) != tt.wantErr {
t.Errorf("wrong error behavior %v, wantErr %v", err, tt.wantErr)
}
for key, val := range tt.has {
if envVal := os.Getenv(key); err == nil && val != envVal {
t.Errorf("wrong value %s of var %s, want %s", envVal, key, val)
}
}
os.Clearenv()
})
}
}
func TestGetDescription(t *testing.T) {
type testSingleEnv struct {
One int `env:"ONE" env-description:"one"`
Two int `env:"TWO" env-description:"two"`
Three int `env:"THREE" env-description:"three"`
}
type testSeveralEnv struct {
One int `env:"ONE,ENO" env-description:"one"`
Two int `env:"TWO,OWT" env-description:"two"`
}
type testDefaultEnv struct {
One int `env:"ONE" env-description:"one" env-default:"1"`
Two int `env:"TWO" env-description:"two" env-default:"2"`
Three int `env:"THREE" env-description:"three" env-default:"3"`
}
type testSubOne struct {
One int `env:"ONE" env-description:"one"`
}
type testSubTwo struct {
Two int `env:"TWO" env-description:"two"`
}
type testDeep struct {
OneStruct testSubOne
TwoStruct testSubTwo
}
type testNoEnv struct {
One int
Two int
Three int
}
header := "test header:"
tests := []struct {
name string
cfg interface{}
header *string
want string
wantErr bool
}{
{
name: "single env",
cfg: &testSingleEnv{},
header: nil,
want: "Environment variables:" +
"\n ONE int\n \tone" +
"\n TWO int\n \ttwo" +
"\n THREE int\n \tthree",
wantErr: false,
},
{
name: "several env",
cfg: &testSeveralEnv{},
header: nil,
want: "Environment variables:" +
"\n ONE int\n \tone" +
"\n ENO int (alternative to ONE)\n \tone" +
"\n TWO int\n \ttwo" +
"\n OWT int (alternative to TWO)\n \ttwo",
wantErr: false,
},
{
name: "default env",
cfg: &testDefaultEnv{},
header: nil,
want: "Environment variables:" +
"\n ONE int\n \tone (default \"1\")" +
"\n TWO int\n \ttwo (default \"2\")" +
"\n THREE int\n \tthree (default \"3\")",
wantErr: false,
},
{
name: "deep structure",
cfg: &testDeep{},
header: nil,
want: "Environment variables:" +
"\n ONE int\n \tone" +
"\n TWO int\n \ttwo",
wantErr: false,
},
{
name: "no env",
cfg: &testNoEnv{},
header: nil,
want: "",
wantErr: false,
},
{
name: "custom header",
cfg: &testSingleEnv{},
header: &header,
want: "test header:" +
"\n ONE int\n \tone" +
"\n TWO int\n \ttwo" +
"\n THREE int\n \tthree",
wantErr: false,
},
{
name: "error",
cfg: 123,
header: nil,
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetDescription(tt.cfg, tt.header)
if (err != nil) != tt.wantErr {
t.Errorf("wrong error behavior %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("wrong description text %s, want %s", got, tt.want)
}
})
}
}
func TestFUsage(t *testing.T) {
type testSingleEnv struct {
One int `env:"ONE" env-description:"one"`
Two int `env:"TWO" env-description:"two"`
Three int `env:"THREE" env-description:"three"`
}
customHeader := "test header:"
tests := []struct {
name string
headerText *string
usageTexts []string
want string
}{
{
name: "no custom usage",
headerText: nil,
usageTexts: nil,
want: "Environment variables:" +
"\n ONE int\n \tone" +
"\n TWO int\n \ttwo" +
"\n THREE int\n \tthree\n",
},
{
name: "custom header",
headerText: &customHeader,
usageTexts: nil,
want: "test header:" +
"\n ONE int\n \tone" +
"\n TWO int\n \ttwo" +
"\n THREE int\n \tthree\n",
},
{
name: "custom usages",
headerText: nil,
usageTexts: []string{
"test1",
"test2",
"test3",
},
want: "test1\ntest2\ntest3\n" +
"\nEnvironment variables:" +
"\n ONE int\n \tone" +
"\n TWO int\n \ttwo" +
"\n THREE int\n \tthree\n",
},
{
name: "custom usages and header",
headerText: &customHeader,
usageTexts: []string{
"test1",
"test2",
"test3",
},
want: "test1\ntest2\ntest3\n" +
"\ntest header:" +
"\n ONE int\n \tone" +
"\n TWO int\n \ttwo" +
"\n THREE int\n \tthree\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := &bytes.Buffer{}
uFuncs := make([]func(), 0, len(tt.usageTexts))
for _, text := range tt.usageTexts {
uFuncs = append(uFuncs, func(a string) func() {
return func() {
fmt.Fprintln(w, a)
}
}(text))
}
var cfg testSingleEnv
FUsage(w, &cfg, tt.headerText, uFuncs...)()
gotRaw, _ := ioutil.ReadAll(w)
got := string(gotRaw)
if got != tt.want {
t.Errorf("wrong output %v, want %v", got, tt.want)
}
})
}
}
func TestReadConfig(t *testing.T) {
type config struct {
Number int64 `yaml:"number" env:"TEST_NUMBER" env-default:"1"`
String string `yaml:"string" env:"TEST_STRING" env-default:"default"`
NoDefault string `yaml:"no-default" env:"TEST_NO_DEFAULT"`
}
tests := []struct {
name string
file string
ext string
env map[string]string
want *config
wantErr bool
}{
{
name: "yaml_only",
file: `
number: 2
string: test
no-default: NoDefault
`,
ext: "yaml",
env: nil,
want: &config{
Number: 1,
String: "default",
NoDefault: "NoDefault",
},
wantErr: false,
},
{
name: "env_only",
file: "none: none",
ext: "yaml",
env: map[string]string{
"TEST_NUMBER": "2",
"TEST_STRING": "test",
},
want: &config{
Number: 2,
String: "test",
NoDefault: "",
},
wantErr: false,
},
{
name: "empty",
file: "none: none",
ext: "yaml",
env: nil,
want: &config{
Number: 1,
String: "default",
NoDefault: "",
},
wantErr: false,
},
{
name: "unknown",
file: "-",
ext: "",
want: nil,
wantErr: true,
},
{
name: "parsing error",
file: "-",
ext: "json",
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpFile, err := ioutil.TempFile(os.TempDir(), fmt.Sprintf("*.%s", tt.ext))
if err != nil {
t.Fatal("cannot create temporary file:", err)
}
defer os.Remove(tmpFile.Name())
text := []byte(tt.file)
if _, err = tmpFile.Write(text); err != nil {
t.Fatal("failed to write to temporary file:", err)
}
for env, val := range tt.env {
os.Setenv(env, val)
}
defer os.Clearenv()
var cfg config
if err = ReadConfig(tmpFile.Name(), &cfg); (err != nil) != tt.wantErr {
t.Errorf("wrong error behavior %v, wantErr %v", err, tt.wantErr)
}
if err == nil && !reflect.DeepEqual(&cfg, tt.want) {
t.Errorf("wrong data %v, want %v", &cfg, tt.want)
}
})
}
}
马建仓 AI 助手
尝试更多
代码解读
代码找茬
代码优化
1
https://gitee.com/zydp/cleanenv.git
git@gitee.com:zydp/cleanenv.git
zydp
cleanenv
cleanenv
master

搜索帮助

0d507c66 1850385 C8b1a773 1850385